diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..a7923835b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @danielgerlag @glucaci diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 83afb451b..253d321bd 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,14 +15,30 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 3.1.x 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: Unit Tests - run: dotnet test test/WorkflowCore.UnitTests --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.UnitTests --no-build --verbosity detailed --logger "trx;LogFileName=UnitTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Unit Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: unit-test-results + path: test-results/ Integration-Tests: runs-on: ubuntu-latest steps: @@ -30,15 +46,31 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: Integration Tests - run: dotnet test test/WorkflowCore.IntegrationTests --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.IntegrationTests --no-build --verbosity detailed --logger "trx;LogFileName=IntegrationTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Integration Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-test-results + path: test-results/ MongoDB-Tests: runs-on: ubuntu-latest steps: @@ -46,15 +78,30 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x - - name: Restore dependencies + 8.0.x + 9.0.x + 10.0.x run: dotnet restore - name: Build run: dotnet build --no-restore - name: MongoDB Tests - run: dotnet test test/WorkflowCore.Tests.MongoDB --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.Tests.MongoDB --no-build --verbosity detailed --logger "trx;LogFileName=MongoDBTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: MongoDB Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: mongodb-test-results + path: test-results/ MySQL-Tests: runs-on: ubuntu-latest steps: @@ -62,15 +109,31 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: MySQL Tests - run: dotnet test test/WorkflowCore.Tests.MySQL --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.Tests.MySQL --no-build --verbosity detailed --logger "trx;LogFileName=MySQLTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: MySQL Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: mysql-test-results + path: test-results/ PostgreSQL-Tests: runs-on: ubuntu-latest steps: @@ -78,15 +141,31 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: PostgreSQL Tests - run: dotnet test test/WorkflowCore.Tests.PostgreSQL --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.Tests.PostgreSQL --no-build --verbosity detailed --logger "trx;LogFileName=PostgreSQLTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: PostgreSQL Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: postgresql-test-results + path: test-results/ Redis-Tests: runs-on: ubuntu-latest steps: @@ -94,31 +173,63 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: Redis Tests - run: dotnet test test/WorkflowCore.Tests.Redis --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.Tests.Redis --no-build --verbosity detailed --logger "trx;LogFileName=RedisTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Redis Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: redis-test-results + path: test-results/ SQLServer-Tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: SQL Server Tests - run: dotnet test test/WorkflowCore.Tests.SqlServer --no-build --verbosity normal -p:ParallelizeTestCollections=false + run: dotnet test test/WorkflowCore.Tests.SqlServer --no-build --verbosity detailed --logger "trx;LogFileName=SQLServerTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: SQL Server Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: sqlserver-test-results + path: test-results/ Elasticsearch-Tests: runs-on: ubuntu-latest steps: @@ -126,12 +237,60 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 3.1.x + dotnet-version: | 6.0.x + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: Elasticsearch Tests - run: dotnet test test/WorkflowCore.Tests.Elasticsearch --no-build --verbosity normal -p:ParallelizeTestCollections=false \ No newline at end of file + run: dotnet test test/WorkflowCore.Tests.Elasticsearch --no-build --verbosity detailed --logger "trx;LogFileName=ElasticsearchTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Elasticsearch Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: elasticsearch-test-results + path: test-results/ + Oracle-Tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: | + 6.0.x + 8.0.x + 9.0.x + 10.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Oracle Tests + run: dotnet test test/WorkflowCore.Tests.Oracle --no-build --verbosity detailed --logger "trx;LogFileName=OracleTests.trx" --logger "console;verbosity=detailed" --results-directory ./test-results -p:ParallelizeTestCollections=false + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Oracle Test Results + path: test-results/*.trx + reporter: dotnet-trx + fail-on-error: false + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: oracle-test-results + path: test-results/ diff --git a/.gitignore b/.gitignore index 085776751..3ff0a4429 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. +plans + # User-specific files *.suo *.user @@ -247,3 +249,4 @@ ModelManifest.xml .idea/.idea.WorkflowCore/.idea riderModule.iml .DS_Store +test-results/ diff --git a/README.md b/README.md index 53ef98486..c129fa840 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Workflow Core [![Build status](https://ci.appveyor.com/api/projects/status/xnby6p5v4ur04u76?svg=true)](https://ci.appveyor.com/project/danielgerlag/workflow-core) +[](https://api.gitsponsors.com/api/badge/link?p=xj6mObb7nZAJGyuABfd8nD5XWf3SE4oUfw0vmCgSiJeIfNlzJAej0FWX8oFdYm6D7bvZpCf6qANVBNPWid4dRQ==) Workflow Core is a light weight embeddable workflow engine targeting .NET Standard. Think: long running processes with multiple tasks that need to track state. It supports pluggable persistence and concurrency providers to allow for multi-node clusters. @@ -139,6 +140,7 @@ There are several persistence providers available as separate Nuget packages. * [Sqlite](src/providers/WorkflowCore.Persistence.Sqlite) * [MySQL](src/providers/WorkflowCore.Persistence.MySQL) * [Redis](src/providers/WorkflowCore.Providers.Redis) +* [Oracle](src/providers/WorkflowCore.Persistence.Oracle) ## Search @@ -148,6 +150,7 @@ These are also available as separate Nuget packages. ## Extensions +* [Azure AI Foundry](src/extensions/WorkflowCore.AI.AzureFoundry) * [User (human) workflows](src/extensions/WorkflowCore.Users) @@ -183,12 +186,12 @@ These are also available as separate Nuget packages. * [Deferred execution & re-entrant steps](src/samples/WorkflowCore.Sample05) +* [Human(User) Workflow](src/samples/WorkflowCore.Sample08) + * [Looping](src/samples/WorkflowCore.Sample02) * [Exposing a REST API](src/samples/WebApiSample) -* [Human(User) Workflow](src/samples/WorkflowCore.Sample08) - * [Testing](src/samples/WorkflowCore.TestSample01) diff --git a/WorkflowCore.sln b/WorkflowCore.sln index e7fb81a2e..685218c34 100644 --- a/WorkflowCore.sln +++ b/WorkflowCore.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29509.3 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EF47161E-E399-451C-BDE8-E92AAD3BD761}" EndProject @@ -154,228 +154,734 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowCore.Sample19", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowCore.Persistence.RavenDB", "src\providers\WorkflowCore.Persistence.RavenDB\WorkflowCore.Persistence.RavenDB.csproj", "{AF205715-C8B7-42EF-BF14-AFC9E7F27242}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowCore.Persistence.Oracle", "src\providers\WorkflowCore.Persistence.Oracle\WorkflowCore.Persistence.Oracle.csproj", "{635629BC-9D5C-40C6-BBD0-060550ECE290}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowCore.Tests.Oracle", "test\WorkflowCore.Tests.Oracle\WorkflowCore.Tests.Oracle.csproj", "{A2837F1C-3740-4375-9069-81AE32C867CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowCore.AI.AzureFoundry", "src\extensions\WorkflowCore.AI.AzureFoundry\WorkflowCore.AI.AzureFoundry.csproj", "{A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowCore.AI.AzureFoundry.Tests", "test\WorkflowCore.AI.AzureFoundry.Tests\WorkflowCore.AI.AzureFoundry.Tests.csproj", "{AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowCore.Sample.AzureFoundry", "src\samples\WorkflowCore.Sample.AzureFoundry\WorkflowCore.Sample.AzureFoundry.csproj", "{D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|x64.Build.0 = Debug|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Debug|x86.Build.0 = Debug|Any CPU {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|Any CPU.Build.0 = Release|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|x64.ActiveCfg = Release|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|x64.Build.0 = Release|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|x86.ActiveCfg = Release|Any CPU + {B7B2EA4D-E7F0-43E2-942A-3A5AA8F57272}.Release|x86.Build.0 = Release|Any CPU {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|x64.Build.0 = Debug|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Debug|x86.Build.0 = Debug|Any CPU {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|Any CPU.Build.0 = Release|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|x64.ActiveCfg = Release|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|x64.Build.0 = Release|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|x86.ActiveCfg = Release|Any CPU + {DD26E7B4-9D3A-4E1E-8585-862DB6DE21EB}.Release|x86.Build.0 = Release|Any CPU {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|x64.Build.0 = Debug|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Debug|x86.Build.0 = Debug|Any CPU {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|Any CPU.Build.0 = Release|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|x64.ActiveCfg = Release|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|x64.Build.0 = Release|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|x86.ActiveCfg = Release|Any CPU + {660FEDAB-D085-476B-9E16-73E42F66DB4F}.Release|x86.Build.0 = Release|Any CPU {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|x64.Build.0 = Debug|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Debug|x86.Build.0 = Debug|Any CPU {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|Any CPU.Build.0 = Release|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|x64.ActiveCfg = Release|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|x64.Build.0 = Release|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|x86.ActiveCfg = Release|Any CPU + {BC6F28F1-9F47-4FFE-AD06-2B74CB89E76B}.Release|x86.Build.0 = Release|Any CPU {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|x64.Build.0 = Debug|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Debug|x86.Build.0 = Debug|Any CPU {FB738255-0304-4A25-B256-22E36EDF9507}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB738255-0304-4A25-B256-22E36EDF9507}.Release|Any CPU.Build.0 = Release|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Release|x64.ActiveCfg = Release|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Release|x64.Build.0 = Release|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Release|x86.ActiveCfg = Release|Any CPU + {FB738255-0304-4A25-B256-22E36EDF9507}.Release|x86.Build.0 = Release|Any CPU {91301F52-E589-499E-97DE-91FA074B790C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {91301F52-E589-499E-97DE-91FA074B790C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Debug|x64.ActiveCfg = Debug|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Debug|x64.Build.0 = Debug|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Debug|x86.ActiveCfg = Debug|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Debug|x86.Build.0 = Debug|Any CPU {91301F52-E589-499E-97DE-91FA074B790C}.Release|Any CPU.ActiveCfg = Release|Any CPU {91301F52-E589-499E-97DE-91FA074B790C}.Release|Any CPU.Build.0 = Release|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Release|x64.ActiveCfg = Release|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Release|x64.Build.0 = Release|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Release|x86.ActiveCfg = Release|Any CPU + {91301F52-E589-499E-97DE-91FA074B790C}.Release|x86.Build.0 = Release|Any CPU {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|x64.ActiveCfg = Debug|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|x64.Build.0 = Debug|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|x86.ActiveCfg = Debug|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Debug|x86.Build.0 = Debug|Any CPU {68883A5C-BD59-404D-A394-18104D6F472C}.Release|Any CPU.ActiveCfg = Release|Any CPU {68883A5C-BD59-404D-A394-18104D6F472C}.Release|Any CPU.Build.0 = Release|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Release|x64.ActiveCfg = Release|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Release|x64.Build.0 = Release|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Release|x86.ActiveCfg = Release|Any CPU + {68883A5C-BD59-404D-A394-18104D6F472C}.Release|x86.Build.0 = Release|Any CPU {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|x64.Build.0 = Debug|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Debug|x86.Build.0 = Debug|Any CPU {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|Any CPU.Build.0 = Release|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|x64.ActiveCfg = Release|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|x64.Build.0 = Release|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|x86.ActiveCfg = Release|Any CPU + {FE54AD67-817A-4CC6-A9EF-C9F7A5122CA4}.Release|x86.Build.0 = Release|Any CPU {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|x64.Build.0 = Debug|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Debug|x86.Build.0 = Debug|Any CPU {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|Any CPU.ActiveCfg = Release|Any CPU {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|Any CPU.Build.0 = Release|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|x64.ActiveCfg = Release|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|x64.Build.0 = Release|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|x86.ActiveCfg = Release|Any CPU + {1DE96D4F-F2CA-4740-8764-BADD1000040A}.Release|x86.Build.0 = Release|Any CPU {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|x64.ActiveCfg = Debug|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|x64.Build.0 = Debug|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|x86.ActiveCfg = Debug|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Debug|x86.Build.0 = Debug|Any CPU {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|Any CPU.ActiveCfg = Release|Any CPU {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|Any CPU.Build.0 = Release|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|x64.ActiveCfg = Release|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|x64.Build.0 = Release|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|x86.ActiveCfg = Release|Any CPU + {9274B938-3996-4FBA-AE2F-0C82009B1116}.Release|x86.Build.0 = Release|Any CPU {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|x64.ActiveCfg = Debug|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|x64.Build.0 = Debug|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|x86.ActiveCfg = Debug|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Debug|x86.Build.0 = Debug|Any CPU {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|Any CPU.ActiveCfg = Release|Any CPU {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|Any CPU.Build.0 = Release|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|x64.ActiveCfg = Release|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|x64.Build.0 = Release|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|x86.ActiveCfg = Release|Any CPU + {86BC1E05-E9CE-4E53-B324-885A2FDBCE74}.Release|x86.Build.0 = Release|Any CPU {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|x64.ActiveCfg = Debug|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|x64.Build.0 = Debug|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|x86.ActiveCfg = Debug|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Debug|x86.Build.0 = Debug|Any CPU {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|Any CPU.Build.0 = Release|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|x64.ActiveCfg = Release|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|x64.Build.0 = Release|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|x86.ActiveCfg = Release|Any CPU + {AFAD87C7-B2EE-451E-BA7E-3F5A91358C48}.Release|x86.Build.0 = Release|Any CPU {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|x64.ActiveCfg = Debug|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|x64.Build.0 = Debug|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|x86.ActiveCfg = Debug|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Debug|x86.Build.0 = Debug|Any CPU {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|Any CPU.ActiveCfg = Release|Any CPU {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|Any CPU.Build.0 = Release|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|x64.ActiveCfg = Release|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|x64.Build.0 = Release|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|x86.ActiveCfg = Release|Any CPU + {8FEAFD74-C304-4F75-BA38-4686BE55C891}.Release|x86.Build.0 = Release|Any CPU {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|x64.ActiveCfg = Debug|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|x64.Build.0 = Debug|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|x86.ActiveCfg = Debug|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Debug|x86.Build.0 = Debug|Any CPU {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|Any CPU.ActiveCfg = Release|Any CPU {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|Any CPU.Build.0 = Release|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|x64.ActiveCfg = Release|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|x64.Build.0 = Release|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|x86.ActiveCfg = Release|Any CPU + {37B598A8-B054-4ABA-884D-96AEF2511600}.Release|x86.Build.0 = Release|Any CPU {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|x64.ActiveCfg = Debug|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|x64.Build.0 = Debug|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|x86.ActiveCfg = Debug|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Debug|x86.Build.0 = Debug|Any CPU {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|Any CPU.ActiveCfg = Release|Any CPU {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|Any CPU.Build.0 = Release|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|x64.ActiveCfg = Release|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|x64.Build.0 = Release|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|x86.ActiveCfg = Release|Any CPU + {17C270A8-EC88-4883-9318-74BB28EFF508}.Release|x86.Build.0 = Release|Any CPU {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|x64.ActiveCfg = Debug|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|x64.Build.0 = Debug|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|x86.ActiveCfg = Debug|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Debug|x86.Build.0 = Debug|Any CPU {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|Any CPU.ActiveCfg = Release|Any CPU {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|Any CPU.Build.0 = Release|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|x64.ActiveCfg = Release|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|x64.Build.0 = Release|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|x86.ActiveCfg = Release|Any CPU + {0631B4BA-D5DD-4C9E-8842-0D370A3D714A}.Release|x86.Build.0 = Release|Any CPU {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|x64.Build.0 = Debug|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Debug|x86.Build.0 = Debug|Any CPU {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|Any CPU.Build.0 = Release|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|x64.ActiveCfg = Release|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|x64.Build.0 = Release|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|x86.ActiveCfg = Release|Any CPU + {9162B6AD-AD06-4C64-9032-0E727643B1B9}.Release|x86.Build.0 = Release|Any CPU {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|x64.ActiveCfg = Debug|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|x64.Build.0 = Debug|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|x86.ActiveCfg = Debug|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Debug|x86.Build.0 = Debug|Any CPU {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|Any CPU.ActiveCfg = Release|Any CPU {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|Any CPU.Build.0 = Release|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|x64.ActiveCfg = Release|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|x64.Build.0 = Release|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|x86.ActiveCfg = Release|Any CPU + {58EC09C7-EC0A-4708-9B6F-FBE6243CEB49}.Release|x86.Build.0 = Release|Any CPU {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|x64.Build.0 = Debug|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Debug|x86.Build.0 = Debug|Any CPU {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|Any CPU.Build.0 = Release|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|x64.ActiveCfg = Release|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|x64.Build.0 = Release|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|x86.ActiveCfg = Release|Any CPU + {8C2BD4D2-43EC-4930-9364-CDA938C01803}.Release|x86.Build.0 = Release|Any CPU {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|x64.Build.0 = Debug|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|x86.ActiveCfg = Debug|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Debug|x86.Build.0 = Debug|Any CPU {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|Any CPU.Build.0 = Release|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|x64.ActiveCfg = Release|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|x64.Build.0 = Release|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|x86.ActiveCfg = Release|Any CPU + {4C4DE624-9D91-484F-8BF7-2D71264EAB8B}.Release|x86.Build.0 = Release|Any CPU {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|x64.Build.0 = Debug|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Debug|x86.Build.0 = Debug|Any CPU {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|Any CPU.Build.0 = Release|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|x64.ActiveCfg = Release|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|x64.Build.0 = Release|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|x86.ActiveCfg = Release|Any CPU + {ED5074AF-A09E-4357-A419-FE3476C0FAE7}.Release|x86.Build.0 = Release|Any CPU {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|x64.ActiveCfg = Debug|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|x64.Build.0 = Debug|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|x86.ActiveCfg = Debug|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Debug|x86.Build.0 = Debug|Any CPU {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|Any CPU.ActiveCfg = Release|Any CPU {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|Any CPU.Build.0 = Release|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|x64.ActiveCfg = Release|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|x64.Build.0 = Release|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|x86.ActiveCfg = Release|Any CPU + {FBF8D151-A3BF-4EB3-8F80-D71618696362}.Release|x86.Build.0 = Release|Any CPU {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|x64.Build.0 = Debug|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Debug|x86.Build.0 = Debug|Any CPU {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|Any CPU.Build.0 = Release|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|x64.ActiveCfg = Release|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|x64.Build.0 = Release|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|x86.ActiveCfg = Release|Any CPU + {50E1AFAC-0B58-43A8-8F03-3A63AAC681FA}.Release|x86.Build.0 = Release|Any CPU {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|x64.Build.0 = Debug|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Debug|x86.Build.0 = Debug|Any CPU {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|Any CPU.ActiveCfg = Release|Any CPU {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|Any CPU.Build.0 = Release|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|x64.ActiveCfg = Release|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|x64.Build.0 = Release|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|x86.ActiveCfg = Release|Any CPU + {5E792455-4C4C-460F-849E-50A5DCED454D}.Release|x86.Build.0 = Release|Any CPU {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|x64.ActiveCfg = Debug|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|x64.Build.0 = Debug|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|x86.ActiveCfg = Debug|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Debug|x86.Build.0 = Debug|Any CPU {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|Any CPU.ActiveCfg = Release|Any CPU {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|Any CPU.Build.0 = Release|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|x64.ActiveCfg = Release|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|x64.Build.0 = Release|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|x86.ActiveCfg = Release|Any CPU + {58D0480F-D05D-4348-86D9-B0A7255700E6}.Release|x86.Build.0 = Release|Any CPU {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|x64.Build.0 = Debug|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Debug|x86.Build.0 = Debug|Any CPU {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|Any CPU.Build.0 = Release|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|x64.ActiveCfg = Release|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|x64.Build.0 = Release|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|x86.ActiveCfg = Release|Any CPU + {BB776411-D279-419F-8697-5C6F52BCD5CD}.Release|x86.Build.0 = Release|Any CPU {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|x64.Build.0 = Debug|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Debug|x86.Build.0 = Debug|Any CPU {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|Any CPU.ActiveCfg = Release|Any CPU {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|Any CPU.Build.0 = Release|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|x64.ActiveCfg = Release|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|x64.Build.0 = Release|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|x86.ActiveCfg = Release|Any CPU + {F9F8F9CD-01D9-468B-856D-6A87F0762A01}.Release|x86.Build.0 = Release|Any CPU {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|x64.Build.0 = Debug|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Debug|x86.Build.0 = Debug|Any CPU {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|Any CPU.Build.0 = Release|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|x64.ActiveCfg = Release|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|x64.Build.0 = Release|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|x86.ActiveCfg = Release|Any CPU + {A4B8E54D-F8FE-4358-AE14-A5354E828938}.Release|x86.Build.0 = Release|Any CPU {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|x64.ActiveCfg = Debug|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|x64.Build.0 = Debug|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|x86.ActiveCfg = Debug|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Debug|x86.Build.0 = Debug|Any CPU {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|Any CPU.Build.0 = Release|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|x64.ActiveCfg = Release|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|x64.Build.0 = Release|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|x86.ActiveCfg = Release|Any CPU + {AAE2E9F9-37EF-4AE1-A200-D37417C9040C}.Release|x86.Build.0 = Release|Any CPU {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|x64.ActiveCfg = Debug|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|x64.Build.0 = Debug|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|x86.ActiveCfg = Debug|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Debug|x86.Build.0 = Debug|Any CPU {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|Any CPU.ActiveCfg = Release|Any CPU {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|Any CPU.Build.0 = Release|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|x64.ActiveCfg = Release|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|x64.Build.0 = Release|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|x86.ActiveCfg = Release|Any CPU + {77C49ACA-203E-428C-A4DB-114DFE454988}.Release|x86.Build.0 = Release|Any CPU {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|x64.Build.0 = Debug|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Debug|x86.Build.0 = Debug|Any CPU {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|Any CPU.Build.0 = Release|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|x64.ActiveCfg = Release|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|x64.Build.0 = Release|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|x86.ActiveCfg = Release|Any CPU + {A2374B7C-4198-40B3-B8FE-FAC3DB3F2539}.Release|x86.Build.0 = Release|Any CPU {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|x64.Build.0 = Debug|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|x86.ActiveCfg = Debug|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Debug|x86.Build.0 = Debug|Any CPU {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|Any CPU.ActiveCfg = Release|Any CPU {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|Any CPU.Build.0 = Release|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|x64.ActiveCfg = Release|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|x64.Build.0 = Release|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|x86.ActiveCfg = Release|Any CPU + {6BC66637-B42A-4334-ADFB-DBEC9F29D293}.Release|x86.Build.0 = Release|Any CPU {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|x64.ActiveCfg = Debug|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|x64.Build.0 = Debug|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|x86.ActiveCfg = Debug|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Debug|x86.Build.0 = Debug|Any CPU {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|Any CPU.ActiveCfg = Release|Any CPU {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|Any CPU.Build.0 = Release|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|x64.ActiveCfg = Release|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|x64.Build.0 = Release|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|x86.ActiveCfg = Release|Any CPU + {62A9709E-27DA-42EE-B94F-5AF431D86354}.Release|x86.Build.0 = Release|Any CPU {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|x64.Build.0 = Debug|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Debug|x86.Build.0 = Debug|Any CPU {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|Any CPU.ActiveCfg = Release|Any CPU {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|Any CPU.Build.0 = Release|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|x64.ActiveCfg = Release|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|x64.Build.0 = Release|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|x86.ActiveCfg = Release|Any CPU + {0E3C1496-8E7C-411A-A536-C7C9CE4EED4E}.Release|x86.Build.0 = Release|Any CPU {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|x64.ActiveCfg = Debug|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|x64.Build.0 = Debug|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Debug|x86.Build.0 = Debug|Any CPU {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|Any CPU.ActiveCfg = Release|Any CPU {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|Any CPU.Build.0 = Release|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|x64.ActiveCfg = Release|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|x64.Build.0 = Release|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|x86.ActiveCfg = Release|Any CPU + {EC497168-5347-4E70-9D9E-9C2F826C1CDF}.Release|x86.Build.0 = Release|Any CPU {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|x64.Build.0 = Debug|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Debug|x86.Build.0 = Debug|Any CPU {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|Any CPU.Build.0 = Release|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|x64.ActiveCfg = Release|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|x64.Build.0 = Release|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|x86.ActiveCfg = Release|Any CPU + {9B7811AC-68D6-4D19-B1E9-65423393ED83}.Release|x86.Build.0 = Release|Any CPU {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|x64.Build.0 = Debug|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Debug|x86.Build.0 = Debug|Any CPU {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|Any CPU.ActiveCfg = Release|Any CPU {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|Any CPU.Build.0 = Release|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|x64.ActiveCfg = Release|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|x64.Build.0 = Release|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|x86.ActiveCfg = Release|Any CPU + {0C9617A9-C8B7-45F6-A54A-261A23AC881B}.Release|x86.Build.0 = Release|Any CPU {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|x64.ActiveCfg = Debug|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|x64.Build.0 = Debug|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|x86.ActiveCfg = Debug|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Debug|x86.Build.0 = Debug|Any CPU {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|Any CPU.ActiveCfg = Release|Any CPU {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|Any CPU.Build.0 = Release|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|x64.ActiveCfg = Release|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|x64.Build.0 = Release|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|x86.ActiveCfg = Release|Any CPU + {42F475BC-95F4-42E1-8CCD-7B9C27487E33}.Release|x86.Build.0 = Release|Any CPU {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|x64.Build.0 = Debug|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Debug|x86.Build.0 = Debug|Any CPU {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|Any CPU.Build.0 = Release|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|x64.ActiveCfg = Release|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|x64.Build.0 = Release|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|x86.ActiveCfg = Release|Any CPU + {7EDD9353-F5C2-414C-AE51-4B0F1C5E105A}.Release|x86.Build.0 = Release|Any CPU {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|x64.Build.0 = Debug|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Debug|x86.Build.0 = Debug|Any CPU {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|Any CPU.ActiveCfg = Release|Any CPU {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|Any CPU.Build.0 = Release|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|x64.ActiveCfg = Release|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|x64.Build.0 = Release|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|x86.ActiveCfg = Release|Any CPU + {5E82A137-0954-46A1-8C46-13C00F0E4842}.Release|x86.Build.0 = Release|Any CPU {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|Any CPU.Build.0 = Debug|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|x64.ActiveCfg = Debug|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|x64.Build.0 = Debug|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|x86.ActiveCfg = Debug|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Debug|x86.Build.0 = Debug|Any CPU {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|Any CPU.ActiveCfg = Release|Any CPU {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|Any CPU.Build.0 = Release|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|x64.ActiveCfg = Release|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|x64.Build.0 = Release|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|x86.ActiveCfg = Release|Any CPU + {453E260D-DBDC-4DDC-BC9C-CA500CED7897}.Release|x86.Build.0 = Release|Any CPU {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|x64.Build.0 = Debug|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Debug|x86.Build.0 = Debug|Any CPU {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|Any CPU.Build.0 = Release|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|x64.ActiveCfg = Release|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|x64.Build.0 = Release|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|x86.ActiveCfg = Release|Any CPU + {DF7F7ECA-1771-40C9-9CD0-AFEFC44E60DE}.Release|x86.Build.0 = Release|Any CPU {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|x64.Build.0 = Debug|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Debug|x86.Build.0 = Debug|Any CPU {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|Any CPU.Build.0 = Release|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|x64.ActiveCfg = Release|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|x64.Build.0 = Release|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|x86.ActiveCfg = Release|Any CPU + {3ECEC028-7E2C-4983-B928-26C073B51BB7}.Release|x86.Build.0 = Release|Any CPU {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|x64.ActiveCfg = Debug|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|x64.Build.0 = Debug|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|x86.ActiveCfg = Debug|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Debug|x86.Build.0 = Debug|Any CPU {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|Any CPU.ActiveCfg = Release|Any CPU {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|Any CPU.Build.0 = Release|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|x64.ActiveCfg = Release|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|x64.Build.0 = Release|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|x86.ActiveCfg = Release|Any CPU + {435C6263-C6F8-4E93-B417-D861E9C22E18}.Release|x86.Build.0 = Release|Any CPU {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|x64.Build.0 = Debug|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Debug|x86.Build.0 = Debug|Any CPU {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|Any CPU.ActiveCfg = Release|Any CPU {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|Any CPU.Build.0 = Release|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|x64.ActiveCfg = Release|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|x64.Build.0 = Release|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|x86.ActiveCfg = Release|Any CPU + {F6348170-B695-4D97-BAE6-4F0F643F3BEF}.Release|x86.Build.0 = Release|Any CPU {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|x64.Build.0 = Debug|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Debug|x86.Build.0 = Debug|Any CPU {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|Any CPU.Build.0 = Release|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|x64.ActiveCfg = Release|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|x64.Build.0 = Release|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|x86.ActiveCfg = Release|Any CPU + {44644716-0CE8-4837-B189-AB65AE2106AA}.Release|x86.Build.0 = Release|Any CPU {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|x64.ActiveCfg = Debug|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|x64.Build.0 = Debug|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|x86.ActiveCfg = Debug|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Debug|x86.Build.0 = Debug|Any CPU {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|Any CPU.ActiveCfg = Release|Any CPU {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|Any CPU.Build.0 = Release|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|x64.ActiveCfg = Release|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|x64.Build.0 = Release|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|x86.ActiveCfg = Release|Any CPU + {78217204-B873-40B9-8875-E3925B2FBCEC}.Release|x86.Build.0 = Release|Any CPU {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|x64.Build.0 = Debug|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Debug|x86.Build.0 = Debug|Any CPU {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|Any CPU.ActiveCfg = Release|Any CPU {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|Any CPU.Build.0 = Release|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|x64.ActiveCfg = Release|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|x64.Build.0 = Release|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|x86.ActiveCfg = Release|Any CPU + {20B98905-08CB-4854-8E2C-A31A078383E9}.Release|x86.Build.0 = Release|Any CPU {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|x64.ActiveCfg = Debug|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|x64.Build.0 = Debug|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|x86.ActiveCfg = Debug|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Debug|x86.Build.0 = Debug|Any CPU {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|Any CPU.ActiveCfg = Release|Any CPU {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|Any CPU.Build.0 = Release|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|x64.ActiveCfg = Release|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|x64.Build.0 = Release|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|x86.ActiveCfg = Release|Any CPU + {5BE6D628-B9DB-4C76-AAEB-8F3800509A84}.Release|x86.Build.0 = Release|Any CPU {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|x64.ActiveCfg = Debug|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|x64.Build.0 = Debug|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|x86.ActiveCfg = Debug|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Debug|x86.Build.0 = Debug|Any CPU {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|Any CPU.ActiveCfg = Release|Any CPU {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|Any CPU.Build.0 = Release|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|x64.ActiveCfg = Release|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|x64.Build.0 = Release|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|x86.ActiveCfg = Release|Any CPU + {E32CF21A-29CC-46D1-8044-FCC327F2B281}.Release|x86.Build.0 = Release|Any CPU {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|x64.ActiveCfg = Debug|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|x64.Build.0 = Debug|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|x86.ActiveCfg = Debug|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Debug|x86.Build.0 = Debug|Any CPU {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|Any CPU.ActiveCfg = Release|Any CPU {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|Any CPU.Build.0 = Release|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|x64.ActiveCfg = Release|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|x64.Build.0 = Release|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|x86.ActiveCfg = Release|Any CPU + {51BB7DCD-01DD-453D-A1E7-17E5E3DBB14C}.Release|x86.Build.0 = Release|Any CPU {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|x64.ActiveCfg = Debug|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|x64.Build.0 = Debug|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|x86.ActiveCfg = Debug|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Debug|x86.Build.0 = Debug|Any CPU {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|Any CPU.ActiveCfg = Release|Any CPU {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|Any CPU.Build.0 = Release|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|x64.ActiveCfg = Release|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|x64.Build.0 = Release|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|x86.ActiveCfg = Release|Any CPU + {54DE20BA-EBA7-4BF0-9BD9-F03766849716}.Release|x86.Build.0 = Release|Any CPU {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|x64.ActiveCfg = Debug|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|x64.Build.0 = Debug|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|x86.ActiveCfg = Debug|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Debug|x86.Build.0 = Debug|Any CPU {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|Any CPU.ActiveCfg = Release|Any CPU {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|Any CPU.Build.0 = Release|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|x64.ActiveCfg = Release|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|x64.Build.0 = Release|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|x86.ActiveCfg = Release|Any CPU + {1223ED47-3E5E-4960-B70D-DFAF550F6666}.Release|x86.Build.0 = Release|Any CPU {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|x64.Build.0 = Debug|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Debug|x86.Build.0 = Debug|Any CPU {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|Any CPU.ActiveCfg = Release|Any CPU {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|Any CPU.Build.0 = Release|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|x64.ActiveCfg = Release|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|x64.Build.0 = Release|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|x86.ActiveCfg = Release|Any CPU + {AF205715-C8B7-42EF-BF14-AFC9E7F27242}.Release|x86.Build.0 = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|x64.ActiveCfg = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|x64.Build.0 = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|x86.ActiveCfg = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Debug|x86.Build.0 = Debug|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|Any CPU.ActiveCfg = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|Any CPU.Build.0 = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|x64.ActiveCfg = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|x64.Build.0 = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|x86.ActiveCfg = Release|Any CPU + {635629BC-9D5C-40C6-BBD0-060550ECE290}.Release|x86.Build.0 = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|x64.Build.0 = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Debug|x86.Build.0 = Debug|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|Any CPU.Build.0 = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|x64.ActiveCfg = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|x64.Build.0 = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|x86.ActiveCfg = Release|Any CPU + {A2837F1C-3740-4375-9069-81AE32C867CA}.Release|x86.Build.0 = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|x64.Build.0 = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Debug|x86.Build.0 = Debug|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|Any CPU.Build.0 = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|x64.ActiveCfg = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|x64.Build.0 = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|x86.ActiveCfg = Release|Any CPU + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1}.Release|x86.Build.0 = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|x64.Build.0 = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Debug|x86.Build.0 = Debug|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|Any CPU.Build.0 = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|x64.ActiveCfg = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|x64.Build.0 = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|x86.ActiveCfg = Release|Any CPU + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8}.Release|x86.Build.0 = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|x64.Build.0 = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Debug|x86.Build.0 = Debug|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|Any CPU.Build.0 = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|x64.ActiveCfg = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|x64.Build.0 = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|x86.ActiveCfg = Release|Any CPU + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -438,6 +944,11 @@ Global {54DE20BA-EBA7-4BF0-9BD9-F03766849716} = {E6CEAD8D-F565-471E-A0DC-676F54EAEDEB} {1223ED47-3E5E-4960-B70D-DFAF550F6666} = {5080DB09-CBE8-4C45-9957-C3BB7651755E} {AF205715-C8B7-42EF-BF14-AFC9E7F27242} = {2EEE6ABD-EE9B-473F-AF2D-6DABB85D7BA2} + {635629BC-9D5C-40C6-BBD0-060550ECE290} = {2EEE6ABD-EE9B-473F-AF2D-6DABB85D7BA2} + {A2837F1C-3740-4375-9069-81AE32C867CA} = {E6CEAD8D-F565-471E-A0DC-676F54EAEDEB} + {A74FFAE5-9788-4C0D-9F77-AF6F6468A1A1} = {6803696C-B19A-4B27-9193-082A02B6F205} + {AAB9DDFF-0A0A-43BE-BF00-2BA13ED526C8} = {E6CEAD8D-F565-471E-A0DC-676F54EAEDEB} + {D75C8112-6A4D-4A13-BB79-2D23DF66E4CB} = {5080DB09-CBE8-4C45-9957-C3BB7651755E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC0FA8D3-6449-4FDA-BB46-ECF58FAD23B4} diff --git a/docs/azure-ai-foundry.md b/docs/azure-ai-foundry.md new file mode 100644 index 000000000..29e3ed799 --- /dev/null +++ b/docs/azure-ai-foundry.md @@ -0,0 +1,452 @@ +# Azure AI Foundry Extension + +The Azure AI Foundry extension enables building AI-powered, agentic workflows with WorkflowCore. It provides workflow steps for LLM invocation, automatic tool execution, embeddings, vector search, and human-in-the-loop review patterns. + +## Installation + +```bash +dotnet add package WorkflowCore.AI.AzureFoundry +``` + +## Overview + +This extension adds six new workflow step types: + +| Step | Description | +|------|-------------| +| `ChatCompletion` | Invoke LLMs with conversation history | +| `AgentLoop` | Agentic workflows with automatic tool calling | +| `ExecuteTool` | Manual tool execution | +| `GenerateEmbedding` | Create vector embeddings | +| `VectorSearch` | Semantic search with Azure AI Search | +| `HumanReview` | Pause for human approval | + +## Configuration + +### Basic Setup + +```csharp +services.AddWorkflow(); + +services.AddAzureFoundry(options => +{ + options.Endpoint = "https://myresource.services.ai.azure.com"; + options.ApiKey = "your-api-key"; + options.DefaultModel = "gpt-4o"; +}); +``` + +### Configuration Options + +| Option | Type | Description | +|--------|------|-------------| +| `Endpoint` | string | Azure AI Foundry endpoint URL | +| `ApiKey` | string | API key for authentication | +| `Credential` | TokenCredential | Azure AD credential (alternative to ApiKey) | +| `DefaultModel` | string | Default LLM model name | +| `DefaultEmbeddingModel` | string | Default embedding model | +| `DefaultTemperature` | float | Default creativity level (0-1) | +| `DefaultMaxTokens` | int | Default response token limit | +| `SearchEndpoint` | string | Azure AI Search endpoint (optional) | +| `SearchApiKey` | string | Azure AI Search API key (optional) | + +## Chat Completion + +The simplest way to invoke an LLM in your workflow: + +```csharp +public class SimpleChatWorkflow : IWorkflow +{ + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a helpful assistant") + .UserMessage(data => data.Question) + .OutputTo(data => data.Answer)); + } +} +``` + +### With Conversation History + +Enable multi-turn conversations: + +```csharp +.ChatCompletion(cfg => cfg + .SystemPrompt("You are a helpful assistant") + .UserMessage(data => data.Question) + .WithHistory() // Maintains conversation context + .OutputTo(data => data.Answer)); +``` + +## Agentic Workflows + +The `AgentLoop` step enables autonomous AI agents that can use tools to accomplish tasks: + +```csharp +public class SupportAgentWorkflow : IWorkflow +{ + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .AgentLoop(cfg => cfg + .SystemPrompt(@"You are a customer support agent. + Use the available tools to help customers. + Always search the knowledge base before answering.") + .Message(data => data.CustomerQuery) + .WithTool() + .WithTool() + .WithTool() + .MaxIterations(10) + .OutputTo(data => data.Response)); + } +} +``` + +### How Agent Loop Works + +1. The LLM receives the user message and tool definitions +2. If the LLM decides to use a tool, it returns a tool call request +3. The step executes the tool and feeds the result back to the LLM +4. This continues until the LLM provides a final response (or max iterations) + +``` +User Message → LLM → Tool Call → Tool Execution → Result → LLM → ... → Final Response +``` + +## Creating Tools + +Tools extend the LLM's capabilities by allowing it to take actions: + +```csharp +public class SearchKnowledgeBase : IAgentTool +{ + private readonly IKnowledgeBaseService _kb; + + public SearchKnowledgeBase(IKnowledgeBaseService kb) + { + _kb = kb; + } + + public string Name => "search_knowledge_base"; + + public string Description => + "Search the knowledge base for articles matching the query"; + + public string ParametersSchema => @"{ + ""type"": ""object"", + ""properties"": { + ""query"": { + ""type"": ""string"", + ""description"": ""Search query"" + }, + ""category"": { + ""type"": ""string"", + ""description"": ""Optional category filter"" + } + }, + ""required"": [""query""] + }"; + + public async Task ExecuteAsync( + string toolCallId, + string arguments, + CancellationToken ct) + { + var args = JsonSerializer.Deserialize(arguments); + var results = await _kb.SearchAsync(args.Query, args.Category, ct); + + if (results.Any()) + { + return ToolResult.Succeeded( + toolCallId, + Name, + JsonSerializer.Serialize(results)); + } + + return ToolResult.Succeeded( + toolCallId, + Name, + "No articles found matching the query."); + } +} +``` + +### Registering Tools + +```csharp +// In your DI setup +services.AddSingleton(); +services.AddSingleton(); + +// After building service provider +var toolRegistry = serviceProvider.GetRequiredService(); +toolRegistry.Register(serviceProvider.GetRequiredService()); +toolRegistry.Register(serviceProvider.GetRequiredService()); +``` + +## Human-in-the-Loop + +For workflows requiring human oversight of AI outputs: + +```csharp +public class ContentReviewWorkflow : IWorkflow +{ + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + + // Generate content with AI + .ChatCompletion(cfg => cfg + .SystemPrompt("Generate marketing copy for the product") + .UserMessage(data => data.ProductDescription) + .OutputTo(data => data.DraftContent)) + + // Human reviews before publishing + .HumanReview(cfg => cfg + .Content(data => data.DraftContent) + .Reviewer(data => data.AssignedEditor) + .Prompt("Review this AI-generated marketing copy") + .OnApproved(data => data.ApprovedContent) + .OnDecision(data => data.ReviewDecision)) + + // Continue based on decision + .If(data => data.ReviewDecision == ReviewDecision.Approved) + .Do(then => then + .Then() + .Input(step => step.Content, data => data.ApprovedContent)); + } +} +``` + +### Getting the Event Key + +There are two ways to get the event key for completing a review: + +**Option 1: Use the workflow ID (simplest)** + +By default, if you don't provide a `CorrelationId`, the event key equals the workflow ID: + +```csharp +// Start workflow +var workflowId = await host.StartWorkflow("ContentReview", data); + +// Later, complete the review using workflowId as the event key +await host.PublishEvent("HumanReview", workflowId, reviewAction); +``` + +**Option 2: Use a custom correlation ID** + +Provide your own correlation ID (e.g., a ticket ID, request ID) for easier integration: + +```csharp +// In your workflow +.HumanReview(cfg => cfg + .Content(data => data.DraftContent) + .CorrelationId(data => data.TicketId) // Use your own ID + .OnApproved(data => data.ApprovedContent)) + +// Complete the review using your known ID +await host.PublishEvent("HumanReview", "TICKET-12345", reviewAction); +``` + +**Option 3: Capture the event key in workflow data** + +Output the event key to your workflow data for later use: + +```csharp +.HumanReview(cfg => cfg + .Content(data => data.DraftContent) + .OnEventKey(data => data.ReviewEventKey) // Capture the key + .OnApproved(data => data.ApprovedContent)) +``` + +### Completing Reviews + +From your UI or API, publish an event to complete the review: + +```csharp +await workflowHost.PublishEvent( + "HumanReview", + eventKey, // The workflow ID, custom correlation ID, or captured event key + new ReviewAction + { + Decision = ReviewDecision.Approved, + Reviewer = "editor@example.com", + Comments = "Approved with minor edits", + ModifiedContent = "Updated content..." // Optional, for modifications + }); +``` + +## RAG (Retrieval-Augmented Generation) + +Combine vector search with LLM generation for knowledge-grounded responses: + +```csharp +public class RAGWorkflow : IWorkflow +{ + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + + // Search for relevant documents + .VectorSearch(cfg => cfg + .Input(s => s.Query, data => data.UserQuestion) + .Input(s => s.IndexName, data => "company-docs") + .Input(s => s.TopK, data => 5) + .Output(s => s.Results, data => data.RelevantDocs)) + + // Generate answer grounded in documents + .ChatCompletion(cfg => cfg + .SystemPrompt(data => $@"Answer based on these documents: + {string.Join("\n", data.RelevantDocs.Select(d => d.Content))} + If the answer isn't in the documents, say so.") + .UserMessage(data => data.UserQuestion) + .OutputTo(data => data.Answer)); + } +} +``` + +## Embeddings + +Generate embeddings for semantic search or similarity: + +```csharp +.GenerateEmbedding(cfg => cfg + .Input(s => s.Text, data => data.Document) + .Output(s => s.Embedding, data => data.DocumentVector)); +``` + +## Authentication + +### API Key (Simplest) + +```csharp +options.ApiKey = Environment.GetEnvironmentVariable("AZURE_AI_API_KEY"); +``` + +### Managed Identity (Production) + +```csharp +options.Credential = new ManagedIdentityCredential(); +``` + +### Service Principal + +```csharp +options.Credential = new ClientSecretCredential( + tenantId: "your-tenant-id", + clientId: "your-client-id", + clientSecret: "your-client-secret" +); +``` + +## Best Practices + +### 1. Set Iteration Limits + +Always set `MaxIterations` on `AgentLoop` to prevent runaway costs: + +```csharp +.AgentLoop(cfg => cfg + .MaxIterations(10) // Stop after 10 LLM calls + ...); +``` + +### 2. Write Clear Tool Descriptions + +The LLM uses descriptions to decide when to use tools: + +```csharp +// ❌ Bad +public string Description => "Gets weather"; + +// ✅ Good +public string Description => + "Get the current weather conditions for a specific city. " + + "Returns temperature, humidity, and conditions."; +``` + +### 3. Use System Prompts Effectively + +Guide the agent's behavior with clear instructions: + +```csharp +.AgentLoop(cfg => cfg + .SystemPrompt(@"You are a customer support agent. + + Guidelines: + 1. Always be polite and professional + 2. Search the knowledge base before answering + 3. If you can't help, create a support ticket + 4. Never share sensitive customer data") + ...); +``` + +### 4. Track Token Usage + +Monitor costs by tracking token consumption: + +```csharp +.ChatCompletion(cfg => cfg + ... + .OutputTokensTo(data => data.TokensUsed)); + +// In your application +logger.LogInformation("Request used {Tokens} tokens", data.TokensUsed); +``` + +### 5. Handle Tool Errors Gracefully + +Return meaningful error messages from tools: + +```csharp +public async Task ExecuteAsync(...) +{ + try + { + var result = await DoWork(); + return ToolResult.Succeeded(id, Name, result); + } + catch (NotFoundException) + { + return ToolResult.Succeeded(id, Name, + "No results found. Try a different search query."); + } + catch (Exception ex) + { + logger.LogError(ex, "Tool execution failed"); + return ToolResult.Failed(id, Name, + "An error occurred. Please try again."); + } +} +``` + +## Samples + +See the [sample project](https://github.com/danielgerlag/workflow-core/tree/master/src/samples/WorkflowCore.Sample.AzureFoundry) for complete working examples. + +## Troubleshooting + +### 404 Resource Not Found + +Ensure your endpoint ends correctly: +- Azure AI Foundry: `https://resource.services.ai.azure.com` +- The extension automatically appends `/models` to the endpoint + +### Authentication Errors + +1. Verify your API key or credentials +2. Check that your Azure AD app has the required permissions +3. For managed identity, ensure the identity has access to the AI resource + +### Tool Not Being Called + +1. Check the tool description is clear about when to use it +2. Verify the tool is registered in the `IToolRegistry` +3. Check the tool's `ParametersSchema` is valid JSON Schema diff --git a/docs/enhanced-test-reporting.md b/docs/enhanced-test-reporting.md new file mode 100644 index 000000000..50675658d --- /dev/null +++ b/docs/enhanced-test-reporting.md @@ -0,0 +1,111 @@ +# Enhanced Test Reporting for GitHub Actions + +This document explains the enhanced test reporting capabilities that have been added to the GitHub Actions workflow. + +## Overview + +The GitHub Actions workflow has been enhanced to provide detailed, individual test results for all test suites in the Workflow Core project. This addresses the requirement to see detailed, individual test results from tests run by GitHub workflows. + +## Key Enhancements + +### 1. Detailed Test Output +- **Enhanced Verbosity**: Changed from `--verbosity normal` to `--verbosity detailed` +- **Detailed Console Logging**: Added `--logger "console;verbosity=detailed"` for comprehensive console output +- **Individual Test Results**: Each test case now shows its execution status, duration, and any error details + +### 2. TRX Test Result Files +- **TRX Format**: Added `--logger "trx;LogFileName={TestSuite}.trx"` to generate XML test result files +- **Structured Data**: TRX files contain structured test data including: + - Test names and fully qualified names + - Test outcomes (Passed, Failed, Skipped) + - Execution times and durations + - Error messages and stack traces for failed tests + - Test categories and traits + +### 3. GitHub Actions Test Reporting +- **Test Reporter Integration**: Added `dorny/test-reporter@v1` action to display test results in the GitHub UI +- **PR Integration**: Test results are automatically displayed in pull request checks +- **Visual Test Summary**: Failed tests are highlighted with detailed error information +- **Test Status Annotations**: Test results appear as GitHub Actions annotations + +### 4. Test Result Artifacts +- **Downloadable Results**: Test result files are uploaded as artifacts for each job +- **Persistent Storage**: Test results are available for download even after workflow completion +- **Individual Job Results**: Each test suite (Unit, Integration, MongoDB, etc.) has separate artifacts + +## What You'll See + +### In GitHub Actions Logs +Before (old format): +``` +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. +Passed! - Failed: 0, Passed: 25, Skipped: 0, Total: 25 +``` + +After (enhanced format): +``` +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. + + Passed WorkflowCore.UnitTests.Services.ExecutionResultProcessorFixture.should_advance_workflow [< 1 ms] + Passed WorkflowCore.UnitTests.Services.ExecutionResultProcessorFixture.should_branch_children [2 ms] + Failed WorkflowCore.UnitTests.Services.SomeTest.example_failing_test [15 ms] + Error Message: + Assert.Equal() Failure + Expected: True + Actual: False + Stack Trace: + at WorkflowCore.UnitTests.Services.SomeTest.example_failing_test() in /path/to/test.cs:line 42 + +Test Run Summary: + Total tests: 25 + Passed: 24 + Failed: 1 + Skipped: 0 +``` + +### In GitHub Pull Requests +- ✅ **Test Status Checks**: Clear pass/fail status for each test suite +- 📊 **Test Summary**: Number of passed, failed, and skipped tests +- 🔍 **Detailed Failure Information**: Click-through to see specific test failures +- 📁 **Downloadable Artifacts**: Access to complete test result files + +### Available Artifacts +Each test job now produces downloadable artifacts: +- `unit-test-results`: Unit test TRX files and logs +- `integration-test-results`: Integration test TRX files and logs +- `mongodb-test-results`: MongoDB-specific test results +- `mysql-test-results`: MySQL-specific test results +- `postgresql-test-results`: PostgreSQL-specific test results +- `redis-test-results`: Redis-specific test results +- `sqlserver-test-results`: SQL Server-specific test results +- `elasticsearch-test-results`: Elasticsearch-specific test results +- `oracle-test-results`: Oracle-specific test results + +## Benefits + +1. **Individual Test Visibility**: See exactly which tests pass or fail +2. **Debugging Support**: Detailed error messages and stack traces +3. **Performance Monitoring**: Test execution times for performance analysis +4. **Historical Data**: Downloadable test results for trend analysis +5. **CI/CD Integration**: Better integration with GitHub's native test reporting features +6. **Developer Experience**: Faster identification of test issues in pull requests + +## File Structure + +After test execution, the following files are generated: +``` +test-results/ +├── UnitTests.trx +├── IntegrationTests.trx +├── MongoDBTests.trx +├── MySQLTests.trx +├── PostgreSQLTests.trx +├── RedisTests.trx +├── SQLServerTests.trx +├── ElasticsearchTests.trx +└── OracleTests.trx +``` + +Each TRX file contains detailed XML data about the test execution results that can be consumed by various reporting tools and integrated development environments. \ No newline at end of file diff --git a/docs/extensions.md b/docs/extensions.md index e370f2b81..ceae2a382 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1,3 +1,4 @@ ## Extensions -* [User (human) workflows](https://github.com/danielgerlag/workflow-core/tree/master/src/extensions/WorkflowCore.Users) \ No newline at end of file +* [User (human) workflows](https://github.com/danielgerlag/workflow-core/tree/master/src/extensions/WorkflowCore.Users) +* [Azure AI Foundry](azure-ai-foundry.md) - AI-powered agentic workflows with LLM invocation, tool execution, and human-in-the-loop patterns \ No newline at end of file diff --git a/docs/images/performance-test-workflows-latency.png b/docs/images/performance-test-workflows-latency.png new file mode 100644 index 000000000..621fc2760 Binary files /dev/null and b/docs/images/performance-test-workflows-latency.png differ diff --git a/docs/images/performance-test-workflows-per-second.png b/docs/images/performance-test-workflows-per-second.png new file mode 100644 index 000000000..8c4b61754 Binary files /dev/null and b/docs/images/performance-test-workflows-per-second.png differ diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 000000000..77fa625a6 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,87 @@ +# Performance Test + +Workflow-core version 3.7.0 was put under test to evaluate its performance. The setup used was single node with the default MemoryPersistenceProvider persistence provider. + +## Methodology + +- Test Environment - Test were run on following two environments one after the other to see how workflow-core performance with a lower vs higher hardware configuration. + - Lower configuration + - Cores: 8 vCPU ([Standard_D8s_v3](https://learn.microsoft.com/azure/virtual-machines/dv3-dsv3-series)) + - RAM: 32 GB + - OS: Linux Ubuntu 20.04 + - dotNet 6 + - Higher configuration + - Cores: 32 vCPU ([Standard_D32as_v4](https://learn.microsoft.com/azure/virtual-machines/dav4-dasv4-series)) + - RAM: 128 GB + - OS: Linux Ubuntu 20.04 + - dotNet 6 +- Test Workflow: Workflow consist of 3 basic steps. These 3 simple steps were chosen to test the performance of the workflow engine with minimal yet sufficient complexity and to avoid any external dependencies. + - Step1 : Generate a [random number](https://learn.microsoft.com/dotnet/api/system.random?view=net-6.0) between 1 to 10 and print it on standard output. + - Step2 : [Conditional step](https://github.com/danielgerlag/workflow-core/blob/master/docs/control-structures.md) + - Step 2.1: If value generate in step1 is > 5 then print it on standard output. + - Step 2.2: If value generate in step1 is <= 5 then print it on standard output. + - Step3: Prints a good bye message on standard output. +- Test tools: + - [NBomber](https://nbomber.com/docs/getting-started/overview/) was used as performance testing framework with C# console app as base. + +- Test scenarios: + - Each type of test run executed for 20 minutes. + - NBomber Load Simulation of type [KeepConstant](https://nbomber.com/docs/using-nbomber/basic-api/load-simulation#keep-constant) copies was used. This type of simulation keep a constant amount of Scenario copies(instances) for a specific period. + - Concurrent copies [1,2,3,4,5,6,7,8,10,12,14,16,32,64,128,256,512,1024] were tested. + - For example if we take Concurrent copies=4 and Duration=20 minutes this means that NBomber will ensure that we have 4 instance of Test Workflow running in parallel for 20 minutes. + +## Results + +- Workflow per seconds - Below tables shows how many workflows we are able to execute per second on two different environment with increasing number of concurrent copies. + +| **Concurrent Copies** | **8 vCPU** | **32 vCPU** | +| :-------------------: | :--------: | :---------: | +| **1** | 300.6 | 504.7 | +| **2** | 310.3 | 513.1 | +| **3** | 309.6 | 519.3 | +| **4** | 314.7 | 521.3 | +| **5** | 312.4 | 519.0 | +| **6** | 314.7 | 517.7 | +| **7** | 318.9 | 516.7 | +| **8** | 318.4 | 517.5 | +| **10** | 322.6 | 517.1 | +| **12** | 319.7 | 517.6 | +| **14** | 322.4 | 518.1 | +| **16** | 327.0 | 515.5 | +| **32** | 327.7 | 515.8 | +| **64** | 330.7 | 523.7 | +| **128** | 332.8 | 526.9 | +| **256** | 332.8 | 529.1 | +| **512** | 332.8 | 529.1 | +| **1024** | 341.3 | 529.1 | + +![Workflows Per Second](./images/performance-test-workflows-per-second.png) + +- Latency - Shows Mean, P99 and P50 latency in milliseconds on two different environment with increasing number of concurrent copies. + +| **Concurrent Copies** | **Mean 8 vCPU** | **Mean 32 vCPU** | **P.99 8 vCPU** | **P.99 32 vCPU** | **P.50 8 vCPU** | **P.50 32 vCPU** | +| :-------------------: | :-------------: | :--------------: | :-------------: | :--------------: | :-------------: | :--------------: | +| **1** | 3.32 | 1.98 | 12.67 | 2.49 | 3.13 | 1.85 | +| **2** | 6.43 | 3.89 | 19.96 | 5.67 | 6.17 | 3.65 | +| **3** | 9.67 | 5.77 | 24.96 | 8.2 | 9.14 | 5.46 | +| **4** | 12.7 | 7.76 | 27.44 | 13.57 | 12.02 | 7.22 | +| **5** | 15.99 | 9.63 | 34.59 | 41.89 | 15.14 | 9.08 | +| **6** | 19.05 | 11.58 | 38.69 | 45.92 | 18.02 | 10.93 | +| **7** | 21.94 | 13.54 | 42.18 | 48.9 | 20.72 | 12.66 | +| **8** | 25.11 | 15.45 | 44.35 | 51.04 | 23.92 | 14.54 | +| **10** | 30.98 | 19.33 | 52.29 | 56.64 | 29.31 | 18.21 | +| **12** | 37.52 | 23.18 | 59.2 | 63.33 | 35.42 | 21.82 | +| **14** | 43.44 | 27.01 | 67.33 | 67.58 | 41.28 | 25.55 | +| **16** | 48.93 | 31.03 | 72.06 | 72.77 | 46.11 | 28.93 | +| **32** | 97.65 | 62.03 | 130.05 | 104.96 | 94.91 | 58.02 | +| **64** | 193.53 | 122.24 | 235.14 | 168.45 | 191.49 | 115.26 | +| **128** | 384.63 | 243.74 | 449.79 | 294.65 | 379.65 | 236.67 | +| **256** | 769.13 | 486.82 | 834.07 | 561.66 | 766.46 | 498.22 | +| **512** | 1538.29 | 968.02 | 1725.44 | 1052.67 | 1542.14 | 962.05 | +| **1024** | 2999.36 | 1935.32 | 3219.46 | 2072.57 | 3086.34 | 1935.36 | + +![Latency](./images/performance-test-workflows-latency.png) + +## References + +- [NBomber](https://nbomber.com/docs/getting-started/overview/) diff --git a/docs/persistence.md b/docs/persistence.md index 8a7fe55fd..00799090a 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -11,3 +11,4 @@ There are several persistence providers available as separate Nuget packages. * [Amazon DynamoDB](https://github.com/danielgerlag/workflow-core/tree/master/src/providers/WorkflowCore.Providers.AWS) * [Cosmos DB](https://github.com/danielgerlag/workflow-core/tree/master/src/providers/WorkflowCore.Providers.Azure) * [Redis](https://github.com/danielgerlag/workflow-core/tree/master/src/providers/WorkflowCore.Providers.Redis) +* [Oracle](https://github.com/danielgerlag/workflow-core/tree/master/src/providers/WorkflowCore.Persistence.Oracle) \ No newline at end of file diff --git a/docs/samples.md b/docs/samples.md index f69290c57..39f1e360e 100644 --- a/docs/samples.md +++ b/docs/samples.md @@ -35,3 +35,10 @@ [Human(User) Workflow](https://github.com/danielgerlag/workflow-core/tree/master/src/samples/WorkflowCore.Sample08) [Workflow Middleware](https://github.com/danielgerlag/workflow-core/tree/master/src/samples/WorkflowCore.Sample19) + +## AI & Agentic Workflow Samples + +[Azure AI Foundry - Chat, Agents & Tools](https://github.com/danielgerlag/workflow-core/tree/master/src/samples/WorkflowCore.Sample.AzureFoundry) - Interactive sample demonstrating: + - Simple LLM chat completion + - Agentic workflows with automatic tool execution (weather, calculator) + - Human-in-the-loop approval workflows diff --git a/mkdocs.yml b/mkdocs.yml index 57ed12c94..5c2da328d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,5 +15,6 @@ nav: - Elasticsearch plugin: elastic-search.md - Test helpers: test-helpers.md - Extensions: extensions.md + - Azure AI Foundry: azure-ai-foundry.md - Samples: samples.md theme: readthedocs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a617157cb..d2c9ba606 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,10 +4,10 @@ https://github.com/danielgerlag/workflow-core/blob/master/LICENSE.md git https://github.com/danielgerlag/workflow-core.git - 3.8.2 - 3.8.2.0 - 3.8.2.0 + 3.17.0 + 3.17.0.0 + 3.17.0.0 https://github.com/danielgerlag/workflow-core/raw/master/src/logo.png - 3.8.2 + 3.17.0 diff --git a/src/WorkflowCore.DSL/Interface/ITypeResolver.cs b/src/WorkflowCore.DSL/Interface/ITypeResolver.cs new file mode 100644 index 000000000..e9e54e49b --- /dev/null +++ b/src/WorkflowCore.DSL/Interface/ITypeResolver.cs @@ -0,0 +1,10 @@ +using System; +using System.Linq; + +namespace WorkflowCore.Interface +{ + public interface ITypeResolver + { + Type FindType(string name); + } +} \ No newline at end of file diff --git a/src/WorkflowCore.DSL/ServiceCollectionExtensions.cs b/src/WorkflowCore.DSL/ServiceCollectionExtensions.cs index 5c4944f4f..a4958e6b4 100644 --- a/src/WorkflowCore.DSL/ServiceCollectionExtensions.cs +++ b/src/WorkflowCore.DSL/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddWorkflowDSL(this IServiceCollection services) { + services.AddTransient(); services.AddTransient(); return services; } diff --git a/src/WorkflowCore.DSL/Services/DefinitionLoader.cs b/src/WorkflowCore.DSL/Services/DefinitionLoader.cs index ef7fadc9a..aa22988eb 100644 --- a/src/WorkflowCore.DSL/Services/DefinitionLoader.cs +++ b/src/WorkflowCore.DSL/Services/DefinitionLoader.cs @@ -5,6 +5,7 @@ using System.Linq.Dynamic.Core; using System.Linq.Expressions; using System.Reflection; +using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using WorkflowCore.Interface; using WorkflowCore.Models; @@ -17,10 +18,46 @@ namespace WorkflowCore.Services.DefinitionStorage public class DefinitionLoader : IDefinitionLoader { private readonly IWorkflowRegistry _registry; + private readonly ITypeResolver _typeResolver; + + // ParsingConfig to allow access to commonly used .NET methods like object.Equals + private static readonly ParsingConfig ParsingConfig = new ParsingConfig + { + AllowNewToEvaluateAnyType = true, + AreContextKeywordsEnabled = true + }; + + // Transform expressions to be compatible with System.Linq.Dynamic.Core 1.6.0+ + private static string TransformExpression(string expression) + { + if (string.IsNullOrEmpty(expression)) + return expression; + + // Transform object.Equals(a, b) to Convert.ToBoolean(a) == Convert.ToBoolean(b) + // This is a simple regex replacement for the common pattern + var objectEqualsPattern = @"object\.Equals\s*\(\s*([^,]+)\s*,\s*([^)]+)\s*\)"; + var transformed = Regex.Replace(expression, objectEqualsPattern, + match => + { + var arg1 = match.Groups[1].Value.Trim(); + var arg2 = match.Groups[2].Value.Trim(); + + // If arg2 is a boolean literal, convert arg1 to boolean and compare + if (arg2 == "true" || arg2 == "false") + { + return $"Convert.ToBoolean({arg1}) == {arg2}"; + } + // Otherwise, convert both to strings for comparison + return $"Convert.ToString({arg1}) == Convert.ToString({arg2})"; + }); + + return transformed; + } - public DefinitionLoader(IWorkflowRegistry registry) + public DefinitionLoader(IWorkflowRegistry registry, ITypeResolver typeResolver) { _registry = registry; + _typeResolver = typeResolver; } public WorkflowDefinition LoadDefinition(string source, Func deserializer) @@ -92,7 +129,7 @@ private WorkflowStepCollection ConvertSteps(ICollection source, Ty { var cancelExprType = typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(dataType, typeof(bool))); var dataParameter = Expression.Parameter(dataType, "data"); - var cancelExpr = DynamicExpressionParser.ParseLambda(new[] { dataParameter }, typeof(bool), nextStep.CancelCondition); + var cancelExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter }, typeof(bool), TransformExpression(nextStep.CancelCondition)); targetStep.CancelCondition = cancelExpr; } @@ -215,15 +252,16 @@ private void AttachOutputs(StepSourceV1 source, Type dataType, Type stepType, Wo foreach (var output in source.Outputs) { var stepParameter = Expression.Parameter(stepType, "step"); - var sourceExpr = DynamicExpressionParser.ParseLambda(new[] { stepParameter }, typeof(object), output.Value); + var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { stepParameter }, typeof(object), TransformExpression(output.Value)); var dataParameter = Expression.Parameter(dataType, "data"); - if(output.Key.Contains(".") || output.Key.Contains("[")) + if (output.Key.Contains(".") || output.Key.Contains("[")) { AttachNestedOutput(output, step, source, sourceExpr, dataParameter); - }else + } + else { AttachDirectlyOutput(output, step, dataType, sourceExpr, dataParameter); } @@ -259,11 +297,11 @@ private void AttachDirectlyOutput(KeyValuePair output, WorkflowS } - private void AttachNestedOutput( KeyValuePair output, WorkflowStep step, StepSourceV1 source, LambdaExpression sourceExpr, ParameterExpression dataParameter) + private void AttachNestedOutput(KeyValuePair output, WorkflowStep step, StepSourceV1 source, LambdaExpression sourceExpr, ParameterExpression dataParameter) { PropertyInfo propertyInfo = null; String[] paths = output.Key.Split('.'); - + Expression targetProperty = dataParameter; bool hasAddOutput = false; @@ -341,7 +379,7 @@ private void AttachOutcomes(StepSourceV1 source, Type dataType, WorkflowStep ste foreach (var nextStep in source.SelectNextStep) { - var sourceDelegate = DynamicExpressionParser.ParseLambda(new[] { dataParameter, outcomeParameter }, typeof(object), nextStep.Value).Compile(); + var sourceDelegate = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, outcomeParameter }, typeof(object), TransformExpression(nextStep.Value)).Compile(); Expression> sourceExpr = (data, outcome) => System.Convert.ToBoolean(sourceDelegate.DynamicInvoke(data, outcome)); step.Outcomes.Add(new ExpressionOutcome(sourceExpr) { @@ -352,13 +390,13 @@ private void AttachOutcomes(StepSourceV1 source, Type dataType, WorkflowStep ste private Type FindType(string name) { - return Type.GetType(name, true, true); + return _typeResolver.FindType(name); } private static Action BuildScalarInputAction(KeyValuePair input, ParameterExpression dataParameter, ParameterExpression contextParameter, ParameterExpression environmentVarsParameter, PropertyInfo stepProperty) { var expr = System.Convert.ToString(input.Value); - var sourceExpr = DynamicExpressionParser.ParseLambda(new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), expr); + var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(expr)); void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) { @@ -391,7 +429,7 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext) { if (prop.Name.StartsWith("@")) { - var sourceExpr = DynamicExpressionParser.ParseLambda(new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), prop.Value.ToString()); + var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(prop.Value.ToString())); object resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables()); subobj.Remove(prop.Name); subobj.Add(prop.Name.TrimStart('@'), JToken.FromObject(resolvedValue)); diff --git a/src/WorkflowCore.DSL/Services/TypeResolver.cs b/src/WorkflowCore.DSL/Services/TypeResolver.cs new file mode 100644 index 000000000..992d38948 --- /dev/null +++ b/src/WorkflowCore.DSL/Services/TypeResolver.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; +using WorkflowCore.Interface; + +namespace WorkflowCore.Services.DefinitionStorage +{ + public class TypeResolver : ITypeResolver + { + public Type FindType(string name) + { + return Type.GetType(name, true, true); + } + } +} diff --git a/src/WorkflowCore.DSL/WorkflowCore.DSL.csproj b/src/WorkflowCore.DSL/WorkflowCore.DSL.csproj index b3ce61cef..9385da3d0 100644 --- a/src/WorkflowCore.DSL/WorkflowCore.DSL.csproj +++ b/src/WorkflowCore.DSL/WorkflowCore.DSL.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -11,7 +11,7 @@ - + diff --git a/src/WorkflowCore/Models/WorkflowOptions.cs b/src/WorkflowCore/Models/WorkflowOptions.cs index 8913c32c2..a4432afff 100644 --- a/src/WorkflowCore/Models/WorkflowOptions.cs +++ b/src/WorkflowCore/Models/WorkflowOptions.cs @@ -8,7 +8,7 @@ namespace WorkflowCore.Models { public class WorkflowOptions { - internal Func PersistanceFactory; + internal Func PersistenceFactory; internal Func QueueFactory; internal Func LockFactory; internal Func EventHubFactory; @@ -29,7 +29,7 @@ public WorkflowOptions(IServiceCollection services) QueueFactory = new Func(sp => new SingleNodeQueueProvider()); LockFactory = new Func(sp => new SingleNodeLockProvider()); - PersistanceFactory = new Func(sp => new TransientMemoryPersistenceProvider(sp.GetService())); + PersistenceFactory = new Func(sp => new TransientMemoryPersistenceProvider(sp.GetService())); SearchIndexFactory = new Func(sp => new NullSearchIndex()); EventHubFactory = new Func(sp => new SingleNodeEventHub(sp.GetService())); } @@ -42,7 +42,7 @@ public WorkflowOptions(IServiceCollection services) public void UsePersistence(Func factory) { - PersistanceFactory = factory; + PersistenceFactory = factory; } public void UseDistributedLockManager(Func factory) diff --git a/src/WorkflowCore/ServiceCollectionExtensions.cs b/src/WorkflowCore/ServiceCollectionExtensions.cs index 760a89d41..f38f44a5b 100644 --- a/src/WorkflowCore/ServiceCollectionExtensions.cs +++ b/src/WorkflowCore/ServiceCollectionExtensions.cs @@ -20,10 +20,10 @@ public static IServiceCollection AddWorkflow(this IServiceCollection services, A var options = new WorkflowOptions(services); setupAction?.Invoke(options); services.AddSingleton(); - services.AddTransient(options.PersistanceFactory); - services.AddTransient(options.PersistanceFactory); - services.AddTransient(options.PersistanceFactory); - services.AddTransient(options.PersistanceFactory); + services.AddTransient(options.PersistenceFactory); + services.AddTransient(options.PersistenceFactory); + services.AddTransient(options.PersistenceFactory); + services.AddTransient(options.PersistenceFactory); services.AddSingleton(options.QueueFactory); services.AddSingleton(options.LockFactory); services.AddSingleton(options.EventHubFactory); diff --git a/src/WorkflowCore/Services/ActivityController.cs b/src/WorkflowCore/Services/ActivityController.cs index e37481521..491f9c47e 100644 --- a/src/WorkflowCore/Services/ActivityController.cs +++ b/src/WorkflowCore/Services/ActivityController.cs @@ -34,7 +34,7 @@ public async Task GetPendingActivity(string activityName, strin { if (!firstPass) await Task.Delay(100); - subscription = await _subscriptionRepository.GetFirstOpenSubscription(Event.EventTypeActivity, activityName, _dateTimeProvider.Now); + subscription = await _subscriptionRepository.GetFirstOpenSubscription(Event.EventTypeActivity, activityName, _dateTimeProvider.UtcNow); if (subscription != null) if (!await _lockProvider.AcquireLock($"sub:{subscription.Id}", CancellationToken.None)) subscription = null; @@ -51,7 +51,7 @@ public async Task GetPendingActivity(string activityName, strin Token = token.Encode(), ActivityName = subscription.EventKey, Parameters = subscription.SubscriptionData, - TokenExpiry = DateTime.MaxValue + TokenExpiry = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc) }; if (!await _subscriptionRepository.SetSubscriptionToken(subscription.Id, result.Token, workerId, result.TokenExpiry)) diff --git a/src/WorkflowCore/Services/BackgroundTasks/EventConsumer.cs b/src/WorkflowCore/Services/BackgroundTasks/EventConsumer.cs index dd7323b01..24a631513 100644 --- a/src/WorkflowCore/Services/BackgroundTasks/EventConsumer.cs +++ b/src/WorkflowCore/Services/BackgroundTasks/EventConsumer.cs @@ -17,7 +17,7 @@ internal class EventConsumer : QueueConsumer, IBackgroundTask private readonly IDistributedLockProvider _lockProvider; private readonly IDateTimeProvider _datetimeProvider; private readonly IGreyList _greylist; - protected override int MaxConcurrentItems => 2; + protected override QueueType Queue => QueueType.Event; public EventConsumer(IWorkflowRepository workflowRepository, ISubscriptionRepository subscriptionRepository, IEventRepository eventRepository, IQueueProvider queueProvider, ILoggerFactory loggerFactory, IServiceProvider serviceProvider, IWorkflowRegistry registry, IDistributedLockProvider lockProvider, WorkflowOptions options, IDateTimeProvider datetimeProvider, IGreyList greylist) @@ -114,7 +114,7 @@ private async Task SeedSubscription(Event evt, EventSubscription sub, Hash if (!await _lockProvider.AcquireLock(sub.WorkflowId, cancellationToken)) { - Logger.LogInformation("Workflow locked {0}", sub.WorkflowId); + Logger.LogInformation("Workflow locked {WorkflowId}", sub.WorkflowId); return false; } @@ -151,4 +151,4 @@ private async Task SeedSubscription(Event evt, EventSubscription sub, Hash } } } -} +} \ No newline at end of file diff --git a/src/WorkflowCore/Services/BackgroundTasks/QueueConsumer.cs b/src/WorkflowCore/Services/BackgroundTasks/QueueConsumer.cs index a5392ecbb..8295ea983 100644 --- a/src/WorkflowCore/Services/BackgroundTasks/QueueConsumer.cs +++ b/src/WorkflowCore/Services/BackgroundTasks/QueueConsumer.cs @@ -24,6 +24,8 @@ internal abstract class QueueConsumer : IBackgroundTask protected Task DispatchTask; private CancellationTokenSource _cancellationTokenSource; private Dictionary _activeTasks; + private List _runningTasks; + private readonly object _runningTasksLock = new object(); private ConcurrentHashSet _secondPasses; protected QueueConsumer(IQueueProvider queueProvider, ILoggerFactory loggerFactory, WorkflowOptions options) @@ -33,6 +35,7 @@ protected QueueConsumer(IQueueProvider queueProvider, ILoggerFactory loggerFacto Logger = loggerFactory.CreateLogger(GetType()); _activeTasks = new Dictionary(); + _runningTasks = new List(); _secondPasses = new ConcurrentHashSet(); } @@ -115,6 +118,10 @@ private async Task Execute() _activeTasks.Add(item, waitHandle); } var task = ExecuteItem(item, waitHandle, activity); + lock (_runningTasksLock) + { + _runningTasks.Add(task); + } } catch (OperationCanceledException) { @@ -122,7 +129,7 @@ private async Task Execute() catch (Exception ex) { Logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } finally { @@ -138,6 +145,25 @@ private async Task Execute() foreach (var handle in toComplete) handle.WaitOne(); + + // Also await all running tasks to ensure proper async completion + Task[] tasksToAwait; + lock (_runningTasksLock) + { + tasksToAwait = _runningTasks.ToArray(); + } + + if (tasksToAwait.Length > 0) + { + try + { + await Task.WhenAll(tasksToAwait); + } + catch + { + // Individual task exceptions are already logged in ExecuteItem + } + } } private async Task ExecuteItem(string itemId, EventWaitHandle waitHandle, Activity activity) @@ -158,7 +184,7 @@ private async Task ExecuteItem(string itemId, EventWaitHandle waitHandle, Activi catch (Exception ex) { Logger.LogError(default(EventId), ex, $"Error executing item {itemId} - {ex.Message}"); - activity?.RecordException(ex); + activity?.AddException(ex); } finally { diff --git a/src/WorkflowCore/Services/BackgroundTasks/RunnablePoller.cs b/src/WorkflowCore/Services/BackgroundTasks/RunnablePoller.cs index fcd2abd92..fc7c0887f 100644 --- a/src/WorkflowCore/Services/BackgroundTasks/RunnablePoller.cs +++ b/src/WorkflowCore/Services/BackgroundTasks/RunnablePoller.cs @@ -86,7 +86,7 @@ await _persistenceStore.ScheduleCommand(new ScheduledCommand() catch (Exception ex) { _logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } } if (_greylist.Contains($"wf:{item}")) @@ -94,7 +94,7 @@ await _persistenceStore.ScheduleCommand(new ScheduledCommand() _logger.LogDebug($"Got greylisted workflow {item}"); continue; } - _logger.LogDebug("Got runnable instance {0}", item); + _logger.LogDebug("Got runnable instance {Item}", item); _greylist.Add($"wf:{item}"); await _queueProvider.QueueWork(item, QueueType.Workflow); } @@ -108,7 +108,7 @@ await _persistenceStore.ScheduleCommand(new ScheduledCommand() catch (Exception ex) { _logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } finally { @@ -145,7 +145,7 @@ await _persistenceStore.ScheduleCommand(new ScheduledCommand() catch (Exception ex) { _logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } } if (_greylist.Contains($"evt:{item}")) @@ -167,7 +167,7 @@ await _persistenceStore.ScheduleCommand(new ScheduledCommand() catch (Exception ex) { _logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } finally { @@ -210,7 +210,7 @@ await _persistenceStore.ProcessCommands(new DateTimeOffset(_dateTimeProvider.Utc catch (Exception ex) { _logger.LogError(ex, ex.Message); - activity?.RecordException(ex); + activity?.AddException(ex); } finally { @@ -218,4 +218,4 @@ await _persistenceStore.ProcessCommands(new DateTimeOffset(_dateTimeProvider.Utc } } } -} +} \ No newline at end of file diff --git a/src/WorkflowCore/Services/BackgroundTasks/WorkflowConsumer.cs b/src/WorkflowCore/Services/BackgroundTasks/WorkflowConsumer.cs index b092f41f6..c7d13138b 100644 --- a/src/WorkflowCore/Services/BackgroundTasks/WorkflowConsumer.cs +++ b/src/WorkflowCore/Services/BackgroundTasks/WorkflowConsumer.cs @@ -8,6 +8,11 @@ namespace WorkflowCore.Services.BackgroundTasks { + /// + /// Background task responsible for consuming workflow items from the queue and processing them. + /// This consumer ensures that workflows are removed from the greylist after processing, + /// regardless of their status, to prevent workflows from getting stuck in "Pending" state. + /// internal class WorkflowConsumer : QueueConsumer, IBackgroundTask { private readonly IDistributedLockProvider _lockProvider; @@ -33,7 +38,7 @@ protected override async Task ProcessItem(string itemId, CancellationToken cance { if (!await _lockProvider.AcquireLock(itemId, cancellationToken)) { - Logger.LogInformation("Workflow locked {0}", itemId); + Logger.LogInformation("Workflow locked {ItemId}", itemId); return; } @@ -57,12 +62,25 @@ protected override async Task ProcessItem(string itemId, CancellationToken cance WorkflowActivity.Enrich(result); await _persistenceStore.PersistWorkflow(workflow, result?.Subscriptions, cancellationToken); await QueueProvider.QueueWork(itemId, QueueType.Index); - _greylist.Remove($"wf:{itemId}"); } } + else + { + Logger.LogDebug("Workflow {ItemId} is not runnable, status: {Status}", itemId, workflow.Status); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing workflow {ItemId}", itemId); + throw; } finally { + // Always remove from greylist regardless of workflow status + // This prevents workflows from being stuck in greylist when they can't be processed + Logger.LogDebug("Removing workflow {ItemId} from greylist", itemId); + _greylist.Remove($"wf:{itemId}"); + await _lockProvider.ReleaseLock(itemId); if ((workflow != null) && (result != null)) { @@ -165,4 +183,4 @@ private async void FutureQueue(WorkflowInstance workflow, CancellationToken canc } } } -} +} \ No newline at end of file diff --git a/src/WorkflowCore/Services/WorkflowActivity.cs b/src/WorkflowCore/Services/WorkflowActivity.cs index b580f98b6..3fe3b720c 100644 --- a/src/WorkflowCore/Services/WorkflowActivity.cs +++ b/src/WorkflowCore/Services/WorkflowActivity.cs @@ -48,6 +48,7 @@ internal static void Enrich(WorkflowInstance workflow, string action) } } + internal static void Enrich(WorkflowStep workflowStep) { var activity = Activity.Current; @@ -57,10 +58,18 @@ internal static void Enrich(WorkflowStep workflowStep) ? "inline" : workflowStep.Name; - activity.DisplayName += $" step {stepName}"; + if (string.IsNullOrEmpty(activity.DisplayName)) + { + activity.DisplayName = $"step {stepName}"; + } + else + { + activity.DisplayName += $" step {stepName}"; + } + activity.SetTag("workflow.step.id", workflowStep.Id); - activity.SetTag("workflow.step.name", workflowStep.Name); - activity.SetTag("workflow.step.type", workflowStep.BodyType.Name); + activity.SetTag("workflow.step.name", stepName); + activity.SetTag("workflow.step.type", workflowStep.BodyType?.Name); } } @@ -69,12 +78,11 @@ internal static void Enrich(WorkflowExecutorResult result) var activity = Activity.Current; if (activity != null) { - activity.SetTag("workflow.subscriptions.count", result.Subscriptions.Count); - activity.SetTag("workflow.errors.count", result.Errors.Count); + activity.SetTag("workflow.subscriptions.count", result?.Subscriptions?.Count); + activity.SetTag("workflow.errors.count", result?.Errors?.Count); - if (result.Errors.Count > 0) + if (result?.Errors?.Count > 0) { - activity.SetStatus(Status.Error); activity.SetStatus(ActivityStatusCode.Error); } } @@ -85,10 +93,10 @@ internal static void Enrich(Event evt) var activity = Activity.Current; if (activity != null) { - activity.DisplayName = $"workflow process {evt.EventName}"; - activity.SetTag("workflow.event.id", evt.Id); - activity.SetTag("workflow.event.name", evt.EventName); - activity.SetTag("workflow.event.processed", evt.IsProcessed); + activity.DisplayName = $"workflow process {evt?.EventName}"; + activity.SetTag("workflow.event.id", evt?.Id); + activity.SetTag("workflow.event.name", evt?.EventName); + activity.SetTag("workflow.event.processed", evt?.IsProcessed); } } diff --git a/src/WorkflowCore/Services/WorkflowController.cs b/src/WorkflowCore/Services/WorkflowController.cs index 6edb63aa7..79272e084 100755 --- a/src/WorkflowCore/Services/WorkflowController.cs +++ b/src/WorkflowCore/Services/WorkflowController.cs @@ -107,7 +107,7 @@ await _eventHub.PublishNotification(new WorkflowStarted public async Task PublishEvent(string eventName, string eventKey, object eventData, DateTime? effectiveDate = null) { - _logger.LogDebug("Creating event {0} {1}", eventName, eventKey); + _logger.LogDebug("Creating event {EventName} {EventKey}", eventName, eventKey); Event evt = new Event(); if (effectiveDate.HasValue) @@ -241,4 +241,4 @@ public void RegisterWorkflow() _registry.RegisterWorkflow(wf); } } -} +} \ No newline at end of file diff --git a/src/WorkflowCore/Services/WorkflowExecutor.cs b/src/WorkflowCore/Services/WorkflowExecutor.cs index 145f02d41..da3e9cd85 100755 --- a/src/WorkflowCore/Services/WorkflowExecutor.cs +++ b/src/WorkflowCore/Services/WorkflowExecutor.cs @@ -47,7 +47,7 @@ public async Task Execute(WorkflowInstance workflow, Can var def = _registry.GetDefinition(workflow.WorkflowDefinitionId, workflow.Version); if (def == null) { - _logger.LogError("Workflow {0} version {1} is not registered", workflow.WorkflowDefinitionId, workflow.Version); + _logger.LogError("Workflow {WorkflowDefinitionId} version {Version} is not registered", workflow.WorkflowDefinitionId, workflow.Version); return wfResult; } @@ -61,7 +61,7 @@ public async Task Execute(WorkflowInstance workflow, Can var step = def.Steps.FindById(pointer.StepId); if (step == null) { - _logger.LogError("Unable to find step {0} in workflow definition", pointer.StepId); + _logger.LogError("Unable to find step {StepId} in workflow definition", pointer.StepId); pointer.SleepUntil = _datetimeProvider.UtcNow.Add(_options.ErrorRetryInterval); wfResult.Errors.Add(new ExecutionError { @@ -83,7 +83,7 @@ public async Task Execute(WorkflowInstance workflow, Can } catch (Exception ex) { - _logger.LogError(ex, "Workflow {0} raised error on step {1} Message: {2}", workflow.Id, pointer.StepId, ex.Message); + _logger.LogError(ex, "Workflow {WorkflowId} raised error on step {StepId} Message: {Message}", workflow.Id, pointer.StepId, ex.Message); wfResult.Errors.Add(new ExecutionError { WorkflowId = workflow.Id, @@ -158,14 +158,14 @@ private async Task ExecuteStep(WorkflowInstance workflow, WorkflowStep step, Exe using (var scope = _scopeProvider.CreateScope(context)) { - _logger.LogDebug("Starting step {0} on workflow {1}", step.Name, workflow.Id); + _logger.LogDebug("Starting step {StepName} on workflow {WorkflowId}", step.Name, workflow.Id); IStepBody body = step.ConstructBody(scope.ServiceProvider); var stepExecutor = scope.ServiceProvider.GetRequiredService(); if (body == null) { - _logger.LogError("Unable to construct step body {0}", step.BodyType.ToString()); + _logger.LogError("Unable to construct step body {BodyType}", step.BodyType.ToString()); pointer.SleepUntil = _datetimeProvider.UtcNow.Add(_options.ErrorRetryInterval); wfResult.Errors.Add(new ExecutionError { @@ -275,4 +275,4 @@ private async Task DetermineNextExecutionTime(WorkflowInstance workflow, Workflo }); } } -} +} \ No newline at end of file diff --git a/src/WorkflowCore/Services/WorkflowHost.cs b/src/WorkflowCore/Services/WorkflowHost.cs index 73c8850fa..d2a09ec70 100644 --- a/src/WorkflowCore/Services/WorkflowHost.cs +++ b/src/WorkflowCore/Services/WorkflowHost.cs @@ -105,7 +105,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } catch (Exception ex) { - activity.RecordException(ex); + activity.AddException(ex); throw; } finally diff --git a/src/WorkflowCore/WorkflowCore.csproj b/src/WorkflowCore/WorkflowCore.csproj index bb567d87c..ab0607213 100644 --- a/src/WorkflowCore/WorkflowCore.csproj +++ b/src/WorkflowCore/WorkflowCore.csproj @@ -26,8 +26,8 @@ - - + + <_Parameter1>WorkflowCore.IntegrationTests diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/CHANGELOG.md b/src/extensions/WorkflowCore.AI.AzureFoundry/CHANGELOG.md new file mode 100644 index 000000000..6753b41ca --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +All notable changes to WorkflowCore.AI.AzureFoundry will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0-beta.1] - 2026-01-27 + +### Added + +- **ChatCompletion Step** - Invoke Azure AI models with conversation history support + - Configurable system prompts, temperature, and max tokens + - Automatic conversation history management + - Token usage tracking for cost monitoring + +- **AgentLoop Step** - Agentic workflows with automatic tool execution + - LLM-driven tool selection and invocation + - Configurable iteration limits to prevent runaway loops + - Support for both automatic and manual tool execution modes + - Tool result tracking and debugging + +- **ExecuteTool Step** - Manual tool execution for fine-grained control + - Direct tool invocation by name with JSON arguments + - Error handling with success/failure results + +- **GenerateEmbedding Step** - Vector embedding generation + - Support for Azure AI embedding models + - Configurable model selection + - Token usage tracking + +- **VectorSearch Step** - Semantic search with Azure AI Search + - Vector similarity search + - OData filter support + - Configurable result count (TopK) + +- **HumanReview Step** - Human-in-the-loop approval workflows + - Pause workflow for human review + - Support for approve, reject, and modify actions + - Configurable reviewer assignment and prompts + +- **Tool Framework** + - `IAgentTool` interface for custom tool implementations + - `IToolRegistry` for tool registration and discovery + - JSON Schema parameter definitions for tool calling + - `ToolResult` with success/failure states + +- **Conversation History Management** + - `IConversationStore` abstraction for pluggable storage + - `InMemoryConversationStore` default implementation + - Automatic thread management per workflow execution + - `ConversationMessage` and `ConversationThread` models + +- **Azure AI Foundry Integration** + - Support for Azure AI Foundry (`services.ai.azure.com`) endpoints + - API key and Azure AD authentication + - Configurable default models and parameters + - Azure AI Search integration for RAG scenarios + +- **Fluent Builder API** + - `ChatCompletion()` extension method + - `AgentLoop()` extension method + - `GenerateEmbedding()` extension method + - `VectorSearch()` extension method + - `HumanReview()` extension method + +### Dependencies + +- Azure.AI.Inference 1.0.0-beta.5 +- Azure.AI.Projects 1.0.0-beta.2 +- Azure.Identity 1.13.0 +- Azure.Search.Documents 11.6.0 + +### Notes + +- This is a beta release - APIs may change before 1.0.0 stable +- Requires .NET Standard 2.0 or higher +- Compatible with WorkflowCore 3.x + +--- + +## [Unreleased] + +### Planned Features + +- Streaming response support for real-time output +- Structured output with JSON schema validation +- Vision/multimodal input support +- OpenTelemetry tracing integration +- Rate limiting and retry configuration +- Batch embedding generation +- More conversation store implementations (Redis, SQL, CosmosDB) diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentLoopBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentLoopBuilder.cs new file mode 100644 index 000000000..942979745 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentLoopBuilder.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Builder interface for configuring AgentLoop steps + /// + public interface IAgentLoopBuilder : IStepBuilder + { + /// + /// Set the system prompt + /// + IAgentLoopBuilder SystemPrompt(string prompt); + + /// + /// Set the system prompt from workflow data + /// + IAgentLoopBuilder SystemPrompt(Expression> expression); + + /// + /// Set the user message + /// + IAgentLoopBuilder Message(string message); + + /// + /// Set the user message from workflow data + /// + IAgentLoopBuilder Message(Expression> expression); + + /// + /// Set the model to use + /// + IAgentLoopBuilder Model(string model); + + /// + /// Set maximum iterations + /// + IAgentLoopBuilder MaxIterations(int maxIterations); + + /// + /// Add a tool by type + /// + IAgentLoopBuilder WithTool() where TTool : IAgentTool; + + /// + /// Add a tool by name + /// + IAgentLoopBuilder WithTool(string toolName); + + /// + /// Enable/disable automatic tool execution + /// + IAgentLoopBuilder AutoExecuteTools(bool auto = true); + + /// + /// Output the response to workflow data + /// + IAgentLoopBuilder OutputTo(Expression> expression); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentTool.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentTool.cs new file mode 100644 index 000000000..57f2dd172 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IAgentTool.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Interface for tools that can be invoked by the LLM + /// + public interface IAgentTool + { + /// + /// Name of the tool (must be unique) + /// + string Name { get; } + + /// + /// Description of what the tool does (used by the LLM to decide when to use it) + /// + string Description { get; } + + /// + /// JSON schema for the tool's parameters + /// + string ParametersSchema { get; } + + /// + /// Execute the tool with the given arguments + /// + /// JSON string containing the tool arguments + /// Cancellation token + /// Tool execution result + Task ExecuteAsync(string toolCallId, string arguments, CancellationToken cancellationToken = default); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionBuilder.cs new file mode 100644 index 000000000..19ed2eef4 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionBuilder.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Builder interface for configuring ChatCompletion steps + /// + public interface IChatCompletionBuilder : IStepBuilder + { + /// + /// Set the system prompt + /// + IChatCompletionBuilder SystemPrompt(string prompt); + + /// + /// Set the system prompt from workflow data + /// + IChatCompletionBuilder SystemPrompt(Expression> expression); + + /// + /// Set the user message + /// + IChatCompletionBuilder UserMessage(string message); + + /// + /// Set the user message from workflow data + /// + IChatCompletionBuilder UserMessage(Expression> expression); + + /// + /// Set the model to use + /// + IChatCompletionBuilder Model(string model); + + /// + /// Set the temperature + /// + IChatCompletionBuilder Temperature(float temperature); + + /// + /// Set the max tokens + /// + IChatCompletionBuilder MaxTokens(int maxTokens); + + /// + /// Include conversation history + /// + IChatCompletionBuilder WithHistory(bool include = true); + + /// + /// Set the thread ID for conversation history + /// + IChatCompletionBuilder ThreadId(Expression> expression); + + /// + /// Output the response to workflow data + /// + IChatCompletionBuilder OutputTo(Expression> expression); + + /// + /// Output token usage to workflow data + /// + IChatCompletionBuilder OutputTokensTo(Expression> expression); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionService.cs new file mode 100644 index 000000000..96aeee867 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IChatCompletionService.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Service for chat completion operations + /// + public interface IChatCompletionService + { + /// + /// Complete a chat conversation + /// + /// Conversation messages + /// Model to use (null for default) + /// Temperature (null for default) + /// Max tokens (null for default) + /// Available tools for the LLM to call + /// Cancellation token + Task CompleteAsync( + IEnumerable messages, + string model = null, + float? temperature = null, + int? maxTokens = null, + IEnumerable tools = null, + CancellationToken cancellationToken = default); + } + + /// + /// Response from a chat completion request + /// + public class ChatCompletionResponse + { + /// + /// The message generated by the model + /// + public ConversationMessage Message { get; set; } + + /// + /// Reason the completion finished (stop, tool_calls, length, content_filter) + /// + public string FinishReason { get; set; } + + /// + /// Number of tokens in the prompt + /// + public int PromptTokens { get; set; } + + /// + /// Number of tokens in the completion + /// + public int CompletionTokens { get; set; } + + /// + /// Total tokens used + /// + public int TotalTokens => PromptTokens + CompletionTokens; + + /// + /// Model used for the completion + /// + public string Model { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IConversationStore.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IConversationStore.cs new file mode 100644 index 000000000..da3f87a62 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IConversationStore.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Abstraction for storing and retrieving conversation threads + /// + public interface IConversationStore + { + /// + /// Get a conversation thread by ID + /// + Task GetThreadAsync(string threadId); + + /// + /// Get or create a thread for a workflow execution pointer + /// + Task GetOrCreateThreadAsync(string workflowInstanceId, string executionPointerId); + + /// + /// Save a conversation thread + /// + Task SaveThreadAsync(ConversationThread thread); + + /// + /// Delete a conversation thread + /// + Task DeleteThreadAsync(string threadId); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IEmbeddingService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IEmbeddingService.cs new file mode 100644 index 000000000..e6689a334 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IEmbeddingService.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Service for generating embeddings + /// + public interface IEmbeddingService + { + /// + /// Generate an embedding vector for the given text + /// + /// Text to embed + /// Model to use (null for default) + /// Cancellation token + /// Embedding vector + Task GenerateEmbeddingAsync( + string text, + string model = null, + CancellationToken cancellationToken = default); + } + + /// + /// Response from an embedding request + /// + public class EmbeddingResponse + { + /// + /// The embedding vector + /// + public float[] Embedding { get; set; } + + /// + /// Dimensionality of the embedding + /// + public int Dimensions => Embedding?.Length ?? 0; + + /// + /// Model used to generate the embedding + /// + public string Model { get; set; } + + /// + /// Tokens used + /// + public int TokensUsed { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IHumanReviewBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IHumanReviewBuilder.cs new file mode 100644 index 000000000..910ee0d92 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IHumanReviewBuilder.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Builder interface for configuring HumanReview steps + /// + public interface IHumanReviewBuilder : IStepBuilder + { + /// + /// Set the content to be reviewed + /// + IHumanReviewBuilder Content(Expression> expression); + + /// + /// Set the reviewer + /// + IHumanReviewBuilder Reviewer(Expression> expression); + + /// + /// Set the review prompt/instructions + /// + IHumanReviewBuilder Prompt(string prompt); + + /// + /// Set a custom correlation ID for the event key. + /// This allows you to use a known value (e.g., ticket ID, request ID) + /// to later complete the review via PublishEvent. + /// If not set, defaults to the workflow ID. + /// + IHumanReviewBuilder CorrelationId(Expression> expression); + + /// + /// Output the event key to workflow data. + /// Use this value to later complete the review via: + /// workflowHost.PublishEvent("HumanReview", eventKey, reviewAction) + /// + IHumanReviewBuilder OnEventKey(Expression> expression); + + /// + /// Output the approved content to workflow data + /// + IHumanReviewBuilder OnApproved(Expression> expression); + + /// + /// Output the decision to workflow data + /// + IHumanReviewBuilder OutputDecisionTo(Expression> expression); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/ISearchService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/ISearchService.cs new file mode 100644 index 000000000..80805fd49 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/ISearchService.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Service for vector search operations + /// + public interface ISearchService + { + /// + /// Search for documents using a text query (will be embedded automatically) + /// + /// Name of the search index + /// Text query + /// Number of results to return + /// Optional OData filter expression + /// Cancellation token + Task SearchAsync( + string indexName, + string query, + int topK = 5, + string filter = null, + CancellationToken cancellationToken = default); + + /// + /// Search for documents using a pre-computed embedding vector + /// + /// Name of the search index + /// Embedding vector + /// Number of results to return + /// Optional OData filter expression + /// Cancellation token + Task SearchByVectorAsync( + string indexName, + float[] embedding, + int topK = 5, + string filter = null, + CancellationToken cancellationToken = default); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IToolRegistry.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IToolRegistry.cs new file mode 100644 index 000000000..74eefd8e2 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Interface/IToolRegistry.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Interface +{ + /// + /// Registry for agent tools + /// + public interface IToolRegistry + { + /// + /// Register a tool + /// + void Register(IAgentTool tool); + + /// + /// Register a tool by type + /// + void Register() where T : IAgentTool; + + /// + /// Get a tool by name + /// + IAgentTool GetTool(string name); + + /// + /// Get all registered tools + /// + IEnumerable GetAllTools(); + + /// + /// Get tool definitions for all registered tools + /// + IEnumerable GetToolDefinitions(); + + /// + /// Check if a tool is registered + /// + bool HasTool(string name); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/AzureFoundryOptions.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/AzureFoundryOptions.cs new file mode 100644 index 000000000..b3e099341 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/AzureFoundryOptions.cs @@ -0,0 +1,58 @@ +using System; +using Azure.Core; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + public class AzureFoundryOptions + { + /// + /// Azure AI Foundry endpoint URL (e.g., "https://myresource.services.ai.azure.com") + /// + public string Endpoint { get; set; } + + /// + /// Azure AI Foundry project name + /// + public string ProjectName { get; set; } + + /// + /// API key for authentication (if not using Azure credentials) + /// + public string ApiKey { get; set; } + + /// + /// Default model to use for chat completions (e.g., "gpt-4o") + /// + public string DefaultModel { get; set; } = "gpt-4o"; + + /// + /// Default model to use for embeddings (e.g., "text-embedding-3-small") + /// + public string DefaultEmbeddingModel { get; set; } = "text-embedding-3-small"; + + /// + /// Azure credential for authentication. If null and ApiKey is null, DefaultAzureCredential will be used. + /// + public TokenCredential Credential { get; set; } + + /// + /// Default temperature for LLM calls (0.0 - 2.0) + /// + public float DefaultTemperature { get; set; } = 0.7f; + + /// + /// Default maximum tokens for LLM responses + /// + public int DefaultMaxTokens { get; set; } = 4096; + + /// + /// Azure AI Search endpoint for vector search operations + /// + public string SearchEndpoint { get; set; } + + /// + /// Azure AI Search API key (optional, uses DefaultAzureCredential if not provided) + /// + public string SearchApiKey { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ChatCompletionResult.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ChatCompletionResult.cs new file mode 100644 index 000000000..f6ad7d1e1 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ChatCompletionResult.cs @@ -0,0 +1,38 @@ +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Result from a chat completion request + /// + public class ChatCompletionResult + { + /// + /// The generated response text + /// + public string Response { get; set; } + + /// + /// Reason the completion finished + /// + public string FinishReason { get; set; } + + /// + /// Number of tokens in the prompt + /// + public int PromptTokens { get; set; } + + /// + /// Number of tokens in the completion + /// + public int CompletionTokens { get; set; } + + /// + /// Total tokens used + /// + public int TotalTokens => PromptTokens + CompletionTokens; + + /// + /// Model used for the completion + /// + public string Model { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationMessage.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationMessage.cs new file mode 100644 index 000000000..1cfa2e65e --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationMessage.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Represents a single message in a conversation thread + /// + public class ConversationMessage + { + /// + /// Unique identifier for this message + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Role of the message sender (system, user, assistant, tool) + /// + public MessageRole Role { get; set; } + + /// + /// Text content of the message + /// + public string Content { get; set; } + + /// + /// Name of the tool that produced this message (for tool role) + /// + public string ToolName { get; set; } + + /// + /// Tool call ID this message is responding to (for tool role) + /// + public string ToolCallId { get; set; } + + /// + /// Tool calls requested by the assistant + /// + public IList ToolCalls { get; set; } + + /// + /// When the message was created + /// + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Additional metadata for the message + /// + public IDictionary Metadata { get; set; } = new Dictionary(); + + /// + /// Token count for this message (if available) + /// + public int? TokenCount { get; set; } + } + + /// + /// Role of a conversation message + /// + public enum MessageRole + { + System, + User, + Assistant, + Tool + } + + /// + /// Represents a tool call request from the LLM + /// + public class ToolCallRequest + { + /// + /// Unique identifier for this tool call + /// + public string Id { get; set; } + + /// + /// Name of the tool to invoke + /// + public string ToolName { get; set; } + + /// + /// JSON arguments for the tool + /// + public string Arguments { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationThread.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationThread.cs new file mode 100644 index 000000000..cc2410bc6 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ConversationThread.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Represents a conversation thread containing multiple messages + /// + public class ConversationThread + { + /// + /// Unique identifier for this conversation thread + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Associated workflow instance ID + /// + public string WorkflowInstanceId { get; set; } + + /// + /// Associated execution pointer ID + /// + public string ExecutionPointerId { get; set; } + + /// + /// Messages in the conversation + /// + public IList Messages { get; set; } = new List(); + + /// + /// When the thread was created + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the thread was last updated + /// + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Additional metadata for the thread + /// + public IDictionary Metadata { get; set; } = new Dictionary(); + + /// + /// Total token count across all messages + /// + public int TotalTokens { get; set; } + + /// + /// Add a message to the thread + /// + public void AddMessage(ConversationMessage message) + { + Messages.Add(message); + UpdatedAt = DateTime.UtcNow; + if (message.TokenCount.HasValue) + { + TotalTokens += message.TokenCount.Value; + } + } + + /// + /// Add a system message + /// + public void AddSystemMessage(string content) + { + AddMessage(new ConversationMessage + { + Role = MessageRole.System, + Content = content + }); + } + + /// + /// Add a user message + /// + public void AddUserMessage(string content) + { + AddMessage(new ConversationMessage + { + Role = MessageRole.User, + Content = content + }); + } + + /// + /// Add an assistant message + /// + public void AddAssistantMessage(string content, IList toolCalls = null) + { + AddMessage(new ConversationMessage + { + Role = MessageRole.Assistant, + Content = content, + ToolCalls = toolCalls + }); + } + + /// + /// Add a tool response message + /// + public void AddToolMessage(string toolCallId, string toolName, string content) + { + AddMessage(new ConversationMessage + { + Role = MessageRole.Tool, + ToolCallId = toolCallId, + ToolName = toolName, + Content = content + }); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ReviewAction.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ReviewAction.cs new file mode 100644 index 000000000..27d5d9521 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ReviewAction.cs @@ -0,0 +1,61 @@ +using System; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Represents a human review action on LLM output + /// + public class ReviewAction + { + /// + /// The decision made by the reviewer + /// + public ReviewDecision Decision { get; set; } + + /// + /// The reviewer's identity + /// + public string Reviewer { get; set; } + + /// + /// Modified content (if the reviewer edited the original) + /// + public string ModifiedContent { get; set; } + + /// + /// Comments from the reviewer + /// + public string Comments { get; set; } + + /// + /// When the review was completed + /// + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } + + /// + /// Possible decisions for human review + /// + public enum ReviewDecision + { + /// + /// Content approved as-is + /// + Approved, + + /// + /// Content approved with modifications + /// + ApprovedWithChanges, + + /// + /// Content rejected + /// + Rejected, + + /// + /// Request regeneration from LLM + /// + Regenerate + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/SearchResult.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/SearchResult.cs new file mode 100644 index 000000000..70f27926b --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/SearchResult.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Result from a vector search operation + /// + public class SearchResult + { + /// + /// Unique identifier of the document + /// + public string DocumentId { get; set; } + + /// + /// Relevance score (higher is more relevant) + /// + public double Score { get; set; } + + /// + /// Document content + /// + public string Content { get; set; } + + /// + /// Document title or name + /// + public string Title { get; set; } + + /// + /// Additional fields from the document + /// + public IDictionary Fields { get; set; } = new Dictionary(); + } + + /// + /// Collection of search results + /// + public class SearchResults + { + /// + /// Individual search results + /// + public IList Results { get; set; } = new List(); + + /// + /// Total number of matching documents + /// + public long? TotalCount { get; set; } + + /// + /// The query that produced these results + /// + public string Query { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolDefinition.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolDefinition.cs new file mode 100644 index 000000000..cbddf1dcd --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolDefinition.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Defines a tool that can be invoked by the LLM + /// + public class ToolDefinition + { + /// + /// Name of the tool (must be unique) + /// + public string Name { get; set; } + + /// + /// Description of what the tool does (used by the LLM) + /// + public string Description { get; set; } + + /// + /// JSON schema for the tool's parameters + /// + public string ParametersSchema { get; set; } + + /// + /// Whether the tool requires confirmation before execution + /// + public bool RequiresConfirmation { get; set; } + + /// + /// Type that implements the tool execution + /// + public Type ImplementationType { get; set; } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolResult.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolResult.cs new file mode 100644 index 000000000..4d7bf341d --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Models/ToolResult.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace WorkflowCore.AI.AzureFoundry.Models +{ + /// + /// Result from executing a tool + /// + public class ToolResult + { + /// + /// Whether the tool executed successfully + /// + public bool Success { get; set; } + + /// + /// Result data from the tool (serialized as string for LLM consumption) + /// + public string Result { get; set; } + + /// + /// Error message if the tool failed + /// + public string Error { get; set; } + + /// + /// The tool call ID this result corresponds to + /// + public string ToolCallId { get; set; } + + /// + /// Name of the tool that was executed + /// + public string ToolName { get; set; } + + /// + /// Execution duration + /// + public TimeSpan Duration { get; set; } + + /// + /// Additional metadata + /// + public IDictionary Metadata { get; set; } = new Dictionary(); + + /// + /// Create a successful result + /// + public static ToolResult Succeeded(string toolCallId, string toolName, string result) + { + return new ToolResult + { + Success = true, + ToolCallId = toolCallId, + ToolName = toolName, + Result = result + }; + } + + /// + /// Create a failed result + /// + public static ToolResult Failed(string toolCallId, string toolName, string error) + { + return new ToolResult + { + Success = false, + ToolCallId = toolCallId, + ToolName = toolName, + Error = error, + Result = $"Error: {error}" + }; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoop.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoop.cs new file mode 100644 index 000000000..f883d48f2 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoop.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for running an agent loop (LLM with automatic tool execution) + /// + public class AgentLoop : StepBodyAsync + { + private readonly IChatCompletionService _chatService; + private readonly IToolRegistry _toolRegistry; + private readonly IConversationStore _conversationStore; + + public AgentLoop( + IChatCompletionService chatService, + IToolRegistry toolRegistry, + IConversationStore conversationStore) + { + _chatService = chatService ?? throw new ArgumentNullException(nameof(chatService)); + _toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry)); + _conversationStore = conversationStore ?? throw new ArgumentNullException(nameof(conversationStore)); + } + + /// + /// System prompt to set the agent's behavior + /// + public string SystemPrompt { get; set; } + + /// + /// User message to start the agent loop + /// + public string UserMessage { get; set; } + + /// + /// Model to use (optional, uses default if not specified) + /// + public string Model { get; set; } + + /// + /// Temperature for response generation + /// + public float? Temperature { get; set; } + + /// + /// Maximum number of iterations (LLM calls) before stopping + /// + public int MaxIterations { get; set; } = 10; + + /// + /// Whether to run in automatic mode (execute tools automatically) + /// + public bool AutomaticMode { get; set; } = true; + + /// + /// Names of tools available to the agent (uses all registered tools if empty) + /// + public IList AvailableTools { get; set; } = new List(); + + /// + /// Thread ID for conversation history (optional) + /// + public string ThreadId { get; set; } + + // Outputs + + /// + /// Final response from the agent + /// + public string Response { get; set; } + + /// + /// Number of iterations executed + /// + public int IterationsExecuted { get; set; } + + /// + /// Tool calls that were made during the loop + /// + public IList ToolResults { get; set; } = new List(); + + /// + /// Total tokens used across all iterations + /// + public int TotalTokens { get; set; } + + /// + /// Whether the loop completed successfully (vs hitting max iterations) + /// + public bool CompletedSuccessfully { get; set; } + + public override async Task RunAsync(IStepExecutionContext context) + { + var thread = await GetOrCreateThread(context); + + if (!string.IsNullOrEmpty(SystemPrompt) && + (thread.Messages.Count == 0 || thread.Messages[0].Role != MessageRole.System)) + { + thread.AddSystemMessage(SystemPrompt); + } + + thread.AddUserMessage(UserMessage); + + var tools = GetAvailableTools(); + var toolDefinitions = tools.Select(t => new ToolDefinition + { + Name = t.Name, + Description = t.Description, + ParametersSchema = t.ParametersSchema + }).ToList(); + + for (int iteration = 0; iteration < MaxIterations; iteration++) + { + IterationsExecuted = iteration + 1; + + var result = await _chatService.CompleteAsync( + thread.Messages, + Model, + Temperature, + cancellationToken: context.CancellationToken, + tools: toolDefinitions); + + TotalTokens += result.TotalTokens; + thread.AddMessage(result.Message); + + if (result.FinishReason == "stop" || result.Message.ToolCalls == null || !result.Message.ToolCalls.Any()) + { + Response = result.Message.Content; + CompletedSuccessfully = true; + await _conversationStore.SaveThreadAsync(thread); + return ExecutionResult.Next(); + } + + if (!AutomaticMode) + { + Response = result.Message.Content; + await _conversationStore.SaveThreadAsync(thread); + return ExecutionResult.Next(); + } + + foreach (var toolCall in result.Message.ToolCalls) + { + var tool = tools.FirstOrDefault(t => t.Name == toolCall.ToolName); + ToolResult toolResult; + + if (tool == null) + { + toolResult = ToolResult.Failed(toolCall.Id, toolCall.ToolName, $"Tool '{toolCall.ToolName}' not found"); + } + else + { + try + { + toolResult = await tool.ExecuteAsync(toolCall.Id, toolCall.Arguments, context.CancellationToken); + } + catch (Exception ex) + { + toolResult = ToolResult.Failed(toolCall.Id, toolCall.ToolName, ex.Message); + } + } + + ToolResults.Add(toolResult); + thread.AddToolMessage(toolCall.Id, toolCall.ToolName, toolResult.Result); + } + } + + CompletedSuccessfully = false; + Response = thread.Messages.LastOrDefault(m => m.Role == MessageRole.Assistant)?.Content; + await _conversationStore.SaveThreadAsync(thread); + + return ExecutionResult.Next(); + } + + private async Task GetOrCreateThread(IStepExecutionContext context) + { + if (!string.IsNullOrEmpty(ThreadId)) + { + var existing = await _conversationStore.GetThreadAsync(ThreadId); + if (existing != null) + return existing; + } + + var thread = await _conversationStore.GetOrCreateThreadAsync( + context.Workflow.Id, + context.ExecutionPointer.Id); + ThreadId = thread.Id; + return thread; + } + + private IList GetAvailableTools() + { + if (AvailableTools != null && AvailableTools.Any()) + { + return AvailableTools + .Select(name => _toolRegistry.GetTool(name)) + .Where(t => t != null) + .ToList(); + } + + return _toolRegistry.GetAllTools().ToList(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoopStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoopStep.cs new file mode 100644 index 000000000..d057bcaee --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/AgentLoopStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for AgentLoop + /// + public class AgentLoopStep : WorkflowStep + { + public override Type BodyType => typeof(AgentLoop); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletion.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletion.cs new file mode 100644 index 000000000..200cda774 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletion.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for chat completion operations + /// + public class ChatCompletion : StepBodyAsync + { + private readonly IChatCompletionService _chatService; + private readonly IConversationStore _conversationStore; + + public ChatCompletion(IChatCompletionService chatService, IConversationStore conversationStore) + { + _chatService = chatService ?? throw new ArgumentNullException(nameof(chatService)); + _conversationStore = conversationStore ?? throw new ArgumentNullException(nameof(conversationStore)); + } + + /// + /// System prompt to set the LLM's behavior + /// + public string SystemPrompt { get; set; } + + /// + /// User message to send to the LLM + /// + public string UserMessage { get; set; } + + /// + /// Model to use (optional, uses default if not specified) + /// + public string Model { get; set; } + + /// + /// Temperature for response generation (0.0 - 2.0) + /// + public float? Temperature { get; set; } + + /// + /// Maximum tokens in the response + /// + public int? MaxTokens { get; set; } + + /// + /// Whether to include conversation history from previous steps + /// + public bool IncludeHistory { get; set; } = true; + + /// + /// Thread ID for conversation history (optional) + /// + public string ThreadId { get; set; } + + // Outputs + + /// + /// The generated response text + /// + public string Response { get; set; } + + /// + /// Reason the completion finished + /// + public string FinishReason { get; set; } + + /// + /// Number of tokens used in the prompt + /// + public int PromptTokens { get; set; } + + /// + /// Number of tokens used in the completion + /// + public int CompletionTokens { get; set; } + + /// + /// Total tokens used + /// + public int TotalTokens { get; set; } + + public override async Task RunAsync(IStepExecutionContext context) + { + var messages = new List(); + + if (IncludeHistory && !string.IsNullOrEmpty(ThreadId)) + { + var thread = await _conversationStore.GetThreadAsync(ThreadId); + if (thread != null) + { + messages.AddRange(thread.Messages); + } + } + else if (IncludeHistory) + { + var thread = await _conversationStore.GetOrCreateThreadAsync( + context.Workflow.Id, + context.ExecutionPointer.Id); + messages.AddRange(thread.Messages); + ThreadId = thread.Id; + } + + if (!string.IsNullOrEmpty(SystemPrompt) && (messages.Count == 0 || messages[0].Role != MessageRole.System)) + { + messages.Insert(0, new ConversationMessage + { + Role = MessageRole.System, + Content = SystemPrompt + }); + } + + messages.Add(new ConversationMessage + { + Role = MessageRole.User, + Content = UserMessage + }); + + var result = await _chatService.CompleteAsync( + messages, + Model, + Temperature, + MaxTokens, + cancellationToken: context.CancellationToken); + + Response = result.Message.Content; + FinishReason = result.FinishReason; + PromptTokens = result.PromptTokens; + CompletionTokens = result.CompletionTokens; + TotalTokens = result.PromptTokens + result.CompletionTokens; + + if (IncludeHistory) + { + var thread = await _conversationStore.GetThreadAsync(ThreadId) + ?? await _conversationStore.GetOrCreateThreadAsync(context.Workflow.Id, context.ExecutionPointer.Id); + + thread.AddUserMessage(UserMessage); + thread.AddAssistantMessage(Response); + await _conversationStore.SaveThreadAsync(thread); + } + + return ExecutionResult.Next(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletionStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletionStep.cs new file mode 100644 index 000000000..1f616e94f --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ChatCompletionStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for ChatCompletion + /// + public class ChatCompletionStep : WorkflowStep + { + public override Type BodyType => typeof(ChatCompletion); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteTool.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteTool.cs new file mode 100644 index 000000000..a5b09b5b8 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteTool.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for executing a single tool + /// + public class ExecuteTool : StepBodyAsync + { + private readonly IToolRegistry _toolRegistry; + + public ExecuteTool(IToolRegistry toolRegistry) + { + _toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry)); + } + + /// + /// Name of the tool to execute + /// + public string ToolName { get; set; } + + /// + /// Tool call ID (for correlating with LLM tool calls) + /// + public string ToolCallId { get; set; } + + /// + /// JSON arguments for the tool + /// + public string Arguments { get; set; } + + // Outputs + + /// + /// Result from the tool execution + /// + public ToolResult Result { get; set; } + + /// + /// Whether the tool executed successfully + /// + public bool Success { get; set; } + + /// + /// Result string from the tool + /// + public string ResultString { get; set; } + + /// + /// Error message if the tool failed + /// + public string Error { get; set; } + + public override async Task RunAsync(IStepExecutionContext context) + { + if (string.IsNullOrEmpty(ToolName)) + { + throw new InvalidOperationException("ToolName is required"); + } + + var tool = _toolRegistry.GetTool(ToolName); + if (tool == null) + { + Result = ToolResult.Failed(ToolCallId, ToolName, $"Tool '{ToolName}' not found"); + Success = false; + Error = Result.Error; + ResultString = Result.Result; + return ExecutionResult.Next(); + } + + try + { + var startTime = DateTime.UtcNow; + Result = await tool.ExecuteAsync(ToolCallId, Arguments, context.CancellationToken); + Result.Duration = DateTime.UtcNow - startTime; + + Success = Result.Success; + ResultString = Result.Result; + Error = Result.Error; + } + catch (Exception ex) + { + Result = ToolResult.Failed(ToolCallId, ToolName, ex.Message); + Success = false; + Error = ex.Message; + ResultString = Result.Result; + } + + return ExecutionResult.Next(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteToolStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteToolStep.cs new file mode 100644 index 000000000..7072e7d8c --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/ExecuteToolStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for ExecuteTool + /// + public class ExecuteToolStep : WorkflowStep + { + public override Type BodyType => typeof(ExecuteTool); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbedding.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbedding.cs new file mode 100644 index 000000000..4b0eb81e2 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbedding.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for generating embeddings + /// + public class GenerateEmbedding : StepBodyAsync + { + private readonly IEmbeddingService _embeddingService; + + public GenerateEmbedding(IEmbeddingService embeddingService) + { + _embeddingService = embeddingService ?? throw new ArgumentNullException(nameof(embeddingService)); + } + + /// + /// Text to generate embedding for + /// + public string Text { get; set; } + + /// + /// Model to use (optional, uses default if not specified) + /// + public string Model { get; set; } + + // Outputs + + /// + /// The generated embedding vector + /// + public float[] Embedding { get; set; } + + /// + /// Dimensionality of the embedding + /// + public int Dimensions { get; set; } + + /// + /// Tokens used for embedding + /// + public int TokensUsed { get; set; } + + public override async Task RunAsync(IStepExecutionContext context) + { + if (string.IsNullOrEmpty(Text)) + { + throw new InvalidOperationException("Text is required for embedding generation"); + } + + var result = await _embeddingService.GenerateEmbeddingAsync( + Text, + Model, + context.CancellationToken); + + Embedding = result.Embedding; + Dimensions = result.Dimensions; + TokensUsed = result.TokensUsed; + + return ExecutionResult.Next(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbeddingStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbeddingStep.cs new file mode 100644 index 000000000..09e570b77 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/GenerateEmbeddingStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for GenerateEmbedding + /// + public class GenerateEmbeddingStep : WorkflowStep + { + public override Type BodyType => typeof(GenerateEmbedding); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReview.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReview.cs new file mode 100644 index 000000000..cf65cf123 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReview.cs @@ -0,0 +1,138 @@ +using System; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for human review of LLM output. + /// + /// To complete a review, publish an event with: + /// - EventName: "HumanReview" + /// - EventKey: The value from the EventKey output property (or your custom CorrelationId if provided) + /// - EventData: A ReviewAction object + /// + public class HumanReview : StepBody + { + public const string EventName = "HumanReview"; + public const string ExtContent = "ContentToReview"; + public const string ExtReviewer = "Reviewer"; + public const string ExtPrompt = "ReviewPrompt"; + public const string ExtEventKey = "EventKey"; + + /// + /// Content to be reviewed + /// + public string Content { get; set; } + + /// + /// Principal/user assigned to review + /// + public string Reviewer { get; set; } + + /// + /// Prompt/instructions for the reviewer + /// + public string ReviewPrompt { get; set; } + + /// + /// Optional custom correlation ID for the event key. + /// If not provided, defaults to "{workflowId}". + /// Use this to correlate reviews with external systems (e.g., ticket ID, request ID). + /// + public string CorrelationId { get; set; } + + // Outputs + + /// + /// The event key to use when publishing the review decision. + /// Store this value to later complete the review via workflowHost.PublishEvent(). + /// + public string EventKey { get; set; } + + /// + /// The review action taken + /// + public ReviewAction ReviewAction { get; set; } + + /// + /// The final approved content (original or modified) + /// + public string ApprovedContent { get; set; } + + /// + /// The decision made by the reviewer + /// + public ReviewDecision Decision { get; set; } + + /// + /// Whether the content was approved (Approved or ApprovedWithChanges) + /// + public bool IsApproved { get; set; } + + /// + /// Comments from the reviewer + /// + public string Comments { get; set; } + + public override ExecutionResult Run(IStepExecutionContext context) + { + if (!context.ExecutionPointer.EventPublished) + { + // Generate the event key - use custom CorrelationId if provided, otherwise use workflowId + EventKey = !string.IsNullOrEmpty(CorrelationId) + ? CorrelationId + : context.Workflow.Id; + + context.ExecutionPointer.ExtensionAttributes[ExtContent] = Content; + context.ExecutionPointer.ExtensionAttributes[ExtReviewer] = Reviewer; + context.ExecutionPointer.ExtensionAttributes[ExtPrompt] = ReviewPrompt; + context.ExecutionPointer.ExtensionAttributes[ExtEventKey] = EventKey; + + var effectiveDate = DateTime.UtcNow; + + return ExecutionResult.WaitForEvent(EventName, EventKey, effectiveDate); + } + + // Restore EventKey from extension attributes for output + if (context.ExecutionPointer.ExtensionAttributes.TryGetValue(ExtEventKey, out var storedKey)) + { + EventKey = storedKey?.ToString(); + } + + if (!(context.ExecutionPointer.EventData is ReviewAction action)) + { + throw new InvalidOperationException("Expected ReviewAction event data"); + } + + ReviewAction = action; + Decision = action.Decision; + Comments = action.Comments; + + switch (action.Decision) + { + case ReviewDecision.Approved: + ApprovedContent = Content; + IsApproved = true; + break; + + case ReviewDecision.ApprovedWithChanges: + ApprovedContent = action.ModifiedContent ?? Content; + IsApproved = true; + break; + + case ReviewDecision.Rejected: + case ReviewDecision.Regenerate: + ApprovedContent = null; + IsApproved = false; + break; + } + + context.ExecutionPointer.ExtensionAttributes["ReviewDecision"] = action.Decision.ToString(); + context.ExecutionPointer.ExtensionAttributes["ReviewedBy"] = action.Reviewer; + + return ExecutionResult.Next(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReviewStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReviewStep.cs new file mode 100644 index 000000000..023d09991 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/HumanReviewStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for HumanReview + /// + public class HumanReviewStep : WorkflowStep + { + public override Type BodyType => typeof(HumanReview); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearch.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearch.cs new file mode 100644 index 000000000..4014860bd --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearch.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// Step body for vector search operations + /// + public class VectorSearch : StepBodyAsync + { + private readonly ISearchService _searchService; + + public VectorSearch(ISearchService searchService) + { + _searchService = searchService ?? throw new ArgumentNullException(nameof(searchService)); + } + + /// + /// Name of the search index + /// + public string IndexName { get; set; } + + /// + /// Text query (will be embedded automatically) + /// + public string Query { get; set; } + + /// + /// Pre-computed embedding vector (optional, if provided Query is ignored) + /// + public float[] Embedding { get; set; } + + /// + /// Number of results to return + /// + public int TopK { get; set; } = 5; + + /// + /// OData filter expression + /// + public string Filter { get; set; } + + // Outputs + + /// + /// Search results + /// + public IList Results { get; set; } + + /// + /// Total count of matching documents + /// + public long? TotalCount { get; set; } + + public override async Task RunAsync(IStepExecutionContext context) + { + if (string.IsNullOrEmpty(IndexName)) + { + throw new InvalidOperationException("IndexName is required for vector search"); + } + + SearchResults searchResults; + + if (Embedding != null && Embedding.Length > 0) + { + searchResults = await _searchService.SearchByVectorAsync( + IndexName, + Embedding, + TopK, + Filter, + context.CancellationToken); + } + else if (!string.IsNullOrEmpty(Query)) + { + searchResults = await _searchService.SearchAsync( + IndexName, + Query, + TopK, + Filter, + context.CancellationToken); + } + else + { + throw new InvalidOperationException("Either Query or Embedding is required for vector search"); + } + + Results = searchResults.Results; + TotalCount = searchResults.TotalCount; + + return ExecutionResult.Next(); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearchStep.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearchStep.cs new file mode 100644 index 000000000..e82fc4a06 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Primitives/VectorSearchStep.cs @@ -0,0 +1,13 @@ +using System; +using WorkflowCore.Models; + +namespace WorkflowCore.AI.AzureFoundry.Primitives +{ + /// + /// WorkflowStep wrapper for VectorSearch + /// + public class VectorSearchStep : WorkflowStep + { + public override Type BodyType => typeof(VectorSearch); + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Properties/AssemblyInfo.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..ffefcf4c7 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WorkflowCore.AI.AzureFoundry.Tests")] diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/ServiceCollectionExtensions.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..2d52c3758 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/ServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.AI.AzureFoundry.Services; + +namespace WorkflowCore.AI.AzureFoundry.ServiceExtensions +{ + /// + /// Extension methods for adding Azure AI Foundry services to the DI container + /// + public static class ServiceCollectionExtensions + { + /// + /// Add Azure AI Foundry services to WorkflowCore + /// + public static IServiceCollection AddAzureFoundry( + this IServiceCollection services, + Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + services.Configure(configure); + + // Core services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // AI services + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Step bodies + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } + + /// + /// Register a tool with the tool registry + /// + public static IServiceCollection AddAgentTool(this IServiceCollection services) + where TTool : class, IAgentTool + { + services.AddTransient(); + services.AddTransient(); + return services; + } + + /// + /// Use a custom conversation store implementation + /// + public static IServiceCollection UseConversationStore(this IServiceCollection services) + where TStore : class, IConversationStore + { + services.AddSingleton(); + return services; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/StepBuilderExtensions.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/StepBuilderExtensions.cs new file mode 100644 index 000000000..48e6a9fbd --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/ServiceExtensions/StepBuilderExtensions.cs @@ -0,0 +1,114 @@ +using System; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.AI.AzureFoundry.Services; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.Interface +{ + /// + /// Extension methods for adding AI steps to workflows + /// + public static class AzureFoundryStepBuilderExtensions + { + /// + /// Add a chat completion step + /// + public static IChatCompletionBuilder ChatCompletion( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var newStep = new ChatCompletionStep(); + builder.WorkflowBuilder.AddStep(newStep); + var stepBuilder = new ChatCompletionBuilder(builder.WorkflowBuilder, newStep); + + configure?.Invoke(stepBuilder); + + newStep.Name = newStep.Name ?? nameof(ChatCompletion); + builder.Step.Outcomes.Add(new ValueOutcome { NextStep = newStep.Id }); + + return stepBuilder; + } + + /// + /// Add an agent loop step + /// + public static IAgentLoopBuilder AgentLoop( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var newStep = new AgentLoopStep(); + builder.WorkflowBuilder.AddStep(newStep); + var stepBuilder = new AgentLoopBuilder(builder.WorkflowBuilder, newStep); + + configure?.Invoke(stepBuilder); + + newStep.Name = newStep.Name ?? nameof(AgentLoop); + builder.Step.Outcomes.Add(new ValueOutcome { NextStep = newStep.Id }); + + return stepBuilder; + } + + /// + /// Add a human review step + /// + public static IHumanReviewBuilder HumanReview( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var newStep = new HumanReviewStep(); + builder.WorkflowBuilder.AddStep(newStep); + var stepBuilder = new HumanReviewBuilder(builder.WorkflowBuilder, newStep); + + configure?.Invoke(stepBuilder); + + newStep.Name = newStep.Name ?? nameof(HumanReview); + builder.Step.Outcomes.Add(new ValueOutcome { NextStep = newStep.Id }); + + return stepBuilder; + } + + /// + /// Add an embedding generation step + /// + public static IStepBuilder GenerateEmbedding( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var stepBuilder = builder.Then(); + configure?.Invoke(stepBuilder); + return stepBuilder; + } + + /// + /// Add a vector search step + /// + public static IStepBuilder VectorSearch( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var stepBuilder = builder.Then(); + configure?.Invoke(stepBuilder); + return stepBuilder; + } + + /// + /// Add a tool execution step + /// + public static IStepBuilder ExecuteTool( + this IStepBuilder builder, + Action> configure = null) + where TStepBody : IStepBody + { + var stepBuilder = builder.Then(); + configure?.Invoke(stepBuilder); + return stepBuilder; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AgentLoopBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AgentLoopBuilder.cs new file mode 100644 index 000000000..48b72f2b9 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AgentLoopBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; +using WorkflowCore.Services; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Builder for AgentLoop steps + /// + public class AgentLoopBuilder : StepBuilder, IAgentLoopBuilder + { + private readonly List _toolNames = new List(); + + public AgentLoopBuilder(IWorkflowBuilder workflowBuilder, WorkflowStep step) + : base(workflowBuilder, step) + { + } + + public IAgentLoopBuilder SystemPrompt(string prompt) + { + Input(s => s.SystemPrompt, d => prompt); + return this; + } + + public IAgentLoopBuilder SystemPrompt(Expression> expression) + { + Input(s => s.SystemPrompt, expression); + return this; + } + + public IAgentLoopBuilder Message(string message) + { + Input(s => s.UserMessage, d => message); + return this; + } + + public IAgentLoopBuilder Message(Expression> expression) + { + Input(s => s.UserMessage, expression); + return this; + } + + public IAgentLoopBuilder Model(string model) + { + Input(s => s.Model, d => model); + return this; + } + + public IAgentLoopBuilder MaxIterations(int maxIterations) + { + Input(s => s.MaxIterations, d => maxIterations); + return this; + } + + public IAgentLoopBuilder WithTool() where TTool : IAgentTool + { + // Tool name will be resolved at runtime + _toolNames.Add(typeof(TTool).Name); + Input(s => s.AvailableTools, d => _toolNames); + return this; + } + + public IAgentLoopBuilder WithTool(string toolName) + { + _toolNames.Add(toolName); + Input(s => s.AvailableTools, d => _toolNames); + return this; + } + + public IAgentLoopBuilder AutoExecuteTools(bool auto = true) + { + Input(s => s.AutomaticMode, d => auto); + return this; + } + + public IAgentLoopBuilder OutputTo(Expression> expression) + { + Output(expression, s => s.Response); + return this; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AzureFoundryClientFactory.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AzureFoundryClientFactory.cs new file mode 100644 index 000000000..fece6317a --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/AzureFoundryClientFactory.cs @@ -0,0 +1,69 @@ +using System; +using Azure; +using Azure.AI.Inference; +using Azure.Identity; +using Microsoft.Extensions.Options; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Factory for creating Azure AI Foundry SDK clients. + /// Supports Azure AI Foundry (services.ai.azure.com) endpoints. + /// + public class AzureFoundryClientFactory + { + private readonly AzureFoundryOptions _options; + + public AzureFoundryClientFactory(IOptions options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Create a ChatCompletionsClient + /// + public ChatCompletionsClient CreateChatClient() + { + var endpoint = BuildEndpoint(); + + if (!string.IsNullOrEmpty(_options.ApiKey)) + { + return new ChatCompletionsClient(endpoint, new AzureKeyCredential(_options.ApiKey)); + } + + var credential = _options.Credential ?? new DefaultAzureCredential(); + return new ChatCompletionsClient(endpoint, credential); + } + + /// + /// Create an EmbeddingsClient + /// + public EmbeddingsClient CreateEmbeddingsClient() + { + var endpoint = BuildEndpoint(); + + if (!string.IsNullOrEmpty(_options.ApiKey)) + { + return new EmbeddingsClient(endpoint, new AzureKeyCredential(_options.ApiKey)); + } + + var credential = _options.Credential ?? new DefaultAzureCredential(); + return new EmbeddingsClient(endpoint, credential); + } + + private Uri BuildEndpoint() + { + var baseEndpoint = _options.Endpoint.TrimEnd('/'); + + // For Azure AI Foundry (services.ai.azure.com), append /models + // The SDK will then call /models/chat/completions or /models/embeddings + if (baseEndpoint.Contains("services.ai.azure.com") && !baseEndpoint.EndsWith("/models")) + { + return new Uri($"{baseEndpoint}/models"); + } + + return new Uri(baseEndpoint); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionBuilder.cs new file mode 100644 index 000000000..0244fbf1f --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; +using WorkflowCore.Services; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Builder for ChatCompletion steps + /// + public class ChatCompletionBuilder : StepBuilder, IChatCompletionBuilder + { + public ChatCompletionBuilder(IWorkflowBuilder workflowBuilder, WorkflowStep step) + : base(workflowBuilder, step) + { + } + + public IChatCompletionBuilder SystemPrompt(string prompt) + { + Input(s => s.SystemPrompt, d => prompt); + return this; + } + + public IChatCompletionBuilder SystemPrompt(Expression> expression) + { + Input(s => s.SystemPrompt, expression); + return this; + } + + public IChatCompletionBuilder UserMessage(string message) + { + Input(s => s.UserMessage, d => message); + return this; + } + + public IChatCompletionBuilder UserMessage(Expression> expression) + { + Input(s => s.UserMessage, expression); + return this; + } + + public IChatCompletionBuilder Model(string model) + { + Input(s => s.Model, d => model); + return this; + } + + public IChatCompletionBuilder Temperature(float temperature) + { + Input(s => s.Temperature, d => temperature); + return this; + } + + public IChatCompletionBuilder MaxTokens(int maxTokens) + { + Input(s => s.MaxTokens, d => maxTokens); + return this; + } + + public IChatCompletionBuilder WithHistory(bool include = true) + { + Input(s => s.IncludeHistory, d => include); + return this; + } + + public IChatCompletionBuilder ThreadId(Expression> expression) + { + Input(s => s.ThreadId, expression); + return this; + } + + public IChatCompletionBuilder OutputTo(Expression> expression) + { + Output(expression, s => s.Response); + return this; + } + + public IChatCompletionBuilder OutputTokensTo(Expression> expression) + { + Output(expression, s => s.TotalTokens); + return this; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionService.cs new file mode 100644 index 000000000..e740fbcf1 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ChatCompletionService.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Service for chat completion operations using Azure AI Inference + /// + public class ChatCompletionService : IChatCompletionService + { + private readonly AzureFoundryClientFactory _clientFactory; + private readonly AzureFoundryOptions _options; + private readonly ILogger _logger; + + public ChatCompletionService( + AzureFoundryClientFactory clientFactory, + IOptions options, + ILogger logger) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CompleteAsync( + IEnumerable messages, + string model = null, + float? temperature = null, + int? maxTokens = null, + IEnumerable tools = null, + CancellationToken cancellationToken = default) + { + var chatMessages = messages.Select(ConvertToSdkMessage).ToList(); + + var requestOptions = new ChatCompletionsOptions(chatMessages) + { + Model = model ?? _options.DefaultModel, + Temperature = temperature ?? _options.DefaultTemperature, + MaxTokens = maxTokens ?? _options.DefaultMaxTokens + }; + + if (tools != null && tools.Any()) + { + foreach (var tool in tools) + { + var functionDef = new FunctionDefinition(tool.Name) + { + Description = tool.Description, + Parameters = BinaryData.FromString(tool.ParametersSchema ?? "{}") + }; + requestOptions.Tools.Add(new ChatCompletionsToolDefinition(functionDef)); + } + } + + _logger.LogDebug("Sending chat completion request with {MessageCount} messages", chatMessages.Count); + + var client = _clientFactory.CreateChatClient(); + var response = await client.CompleteAsync(requestOptions, cancellationToken); + var completion = response.Value; + + var responseMessage = new ConversationMessage + { + Role = MessageRole.Assistant, + Content = completion.Content, + TokenCount = completion.Usage?.TotalTokens + }; + + if (completion.ToolCalls != null && completion.ToolCalls.Any()) + { + responseMessage.ToolCalls = completion.ToolCalls + .Select(tc => new ToolCallRequest + { + // Use the SDK-provided ID, but ensure it's not too long (API max is 40 chars) + Id = EnsureValidToolCallId(tc.Id), + ToolName = tc.Function?.Name, + Arguments = tc.Function?.Arguments + }) + .ToList(); + } + + return new ChatCompletionResponse + { + Message = responseMessage, + FinishReason = completion.FinishReason?.ToString() ?? "unknown", + PromptTokens = completion.Usage?.PromptTokens ?? 0, + CompletionTokens = completion.Usage?.CompletionTokens ?? 0, + Model = model ?? _options.DefaultModel + }; + } + + private ChatRequestMessage ConvertToSdkMessage(ConversationMessage message) + { + switch (message.Role) + { + case MessageRole.System: + return new ChatRequestSystemMessage(message.Content); + + case MessageRole.User: + return new ChatRequestUserMessage(message.Content); + + case MessageRole.Assistant: + var assistantMessage = new ChatRequestAssistantMessage(message.Content ?? string.Empty); + if (message.ToolCalls != null) + { + foreach (var toolCall in message.ToolCalls) + { + var validId = EnsureValidToolCallId(toolCall.Id); + _logger.LogDebug("Assistant tool call ID: original={OriginalLength}, truncated={TruncatedLength}", + toolCall.Id?.Length ?? 0, validId?.Length ?? 0); + assistantMessage.ToolCalls.Add(new ChatCompletionsToolCall( + validId, + new FunctionCall(toolCall.ToolName, toolCall.Arguments))); + } + } + return assistantMessage; + + case MessageRole.Tool: + var validToolCallId = EnsureValidToolCallId(message.ToolCallId); + _logger.LogDebug("Tool message tool_call_id: original={OriginalLength}, truncated={TruncatedLength}", + message.ToolCallId?.Length ?? 0, validToolCallId?.Length ?? 0); + // Constructor order is (content, toolCallId) + return new ChatRequestToolMessage(message.Content, validToolCallId); + + default: + throw new ArgumentException($"Unknown message role: {message.Role}"); + } + } + + /// + /// Ensures tool call ID is valid (max 40 characters per API requirement) + /// + private static string EnsureValidToolCallId(string id) + { + if (string.IsNullOrEmpty(id)) + { + return "call_" + Guid.NewGuid().ToString("N").Substring(0, 24); + } + + if (id.Length > 40) + { + return id.Substring(0, 40); + } + + return id; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/EmbeddingService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/EmbeddingService.cs new file mode 100644 index 000000000..a9bbea6c2 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/EmbeddingService.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Service for generating embeddings using Azure AI Inference + /// + public class EmbeddingService : IEmbeddingService + { + private readonly AzureFoundryClientFactory _clientFactory; + private readonly AzureFoundryOptions _options; + private readonly ILogger _logger; + + public EmbeddingService( + AzureFoundryClientFactory clientFactory, + IOptions options, + ILogger logger) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GenerateEmbeddingAsync( + string text, + string model = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Text cannot be null or empty", nameof(text)); + + _logger.LogDebug("Generating embedding for text of length {Length}", text.Length); + + var options = new EmbeddingsOptions(new List { text }) + { + Model = model ?? _options.DefaultEmbeddingModel + }; + var client = _clientFactory.CreateEmbeddingsClient(); + var response = await client.EmbedAsync(options, cancellationToken); + var embedding = response.Value; + + var embeddingItem = embedding.Data.FirstOrDefault(); + float[] vector = null; + if (embeddingItem?.Embedding != null) + { + var bytes = embeddingItem.Embedding.ToArray(); + vector = new float[bytes.Length / sizeof(float)]; + Buffer.BlockCopy(bytes, 0, vector, 0, bytes.Length); + } + + return new EmbeddingResponse + { + Embedding = vector, + Model = model ?? _options.DefaultEmbeddingModel, + TokensUsed = embedding.Usage?.TotalTokens ?? 0 + }; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/HumanReviewBuilder.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/HumanReviewBuilder.cs new file mode 100644 index 000000000..0cd7957f4 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/HumanReviewBuilder.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq.Expressions; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; +using WorkflowCore.Services; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Builder for HumanReview steps + /// + public class HumanReviewBuilder : StepBuilder, IHumanReviewBuilder + { + public HumanReviewBuilder(IWorkflowBuilder workflowBuilder, WorkflowStep step) + : base(workflowBuilder, step) + { + } + + public IHumanReviewBuilder Content(Expression> expression) + { + Input(s => s.Content, expression); + return this; + } + + public IHumanReviewBuilder Reviewer(Expression> expression) + { + Input(s => s.Reviewer, expression); + return this; + } + + public IHumanReviewBuilder Prompt(string prompt) + { + Input(s => s.ReviewPrompt, d => prompt); + return this; + } + + public IHumanReviewBuilder CorrelationId(Expression> expression) + { + Input(s => s.CorrelationId, expression); + return this; + } + + public IHumanReviewBuilder OnEventKey(Expression> expression) + { + Output(expression, s => s.EventKey); + return this; + } + + public IHumanReviewBuilder OnApproved(Expression> expression) + { + Output(expression, s => s.ApprovedContent); + return this; + } + + public IHumanReviewBuilder OutputDecisionTo(Expression> expression) + { + Output(expression, s => s.Decision); + return this; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/InMemoryConversationStore.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/InMemoryConversationStore.cs new file mode 100644 index 000000000..c6fca1b75 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/InMemoryConversationStore.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// In-memory implementation of conversation store (for development/testing) + /// + public class InMemoryConversationStore : IConversationStore + { + private readonly ConcurrentDictionary _threads = + new ConcurrentDictionary(); + + private readonly ConcurrentDictionary _workflowThreadMap = + new ConcurrentDictionary(); + + public Task GetThreadAsync(string threadId) + { + _threads.TryGetValue(threadId, out var thread); + return Task.FromResult(thread); + } + + public Task GetOrCreateThreadAsync(string workflowInstanceId, string executionPointerId) + { + var key = $"{workflowInstanceId}:{executionPointerId}"; + + if (_workflowThreadMap.TryGetValue(key, out var threadId)) + { + if (_threads.TryGetValue(threadId, out var existingThread)) + { + return Task.FromResult(existingThread); + } + } + + var thread = new ConversationThread + { + WorkflowInstanceId = workflowInstanceId, + ExecutionPointerId = executionPointerId + }; + + _threads[thread.Id] = thread; + _workflowThreadMap[key] = thread.Id; + + return Task.FromResult(thread); + } + + public Task SaveThreadAsync(ConversationThread thread) + { + _threads[thread.Id] = thread; + + if (!string.IsNullOrEmpty(thread.WorkflowInstanceId) && !string.IsNullOrEmpty(thread.ExecutionPointerId)) + { + var key = $"{thread.WorkflowInstanceId}:{thread.ExecutionPointerId}"; + _workflowThreadMap[key] = thread.Id; + } + + return Task.CompletedTask; + } + + public Task DeleteThreadAsync(string threadId) + { + if (_threads.TryRemove(threadId, out var thread)) + { + if (!string.IsNullOrEmpty(thread.WorkflowInstanceId) && !string.IsNullOrEmpty(thread.ExecutionPointerId)) + { + var key = $"{thread.WorkflowInstanceId}:{thread.ExecutionPointerId}"; + _workflowThreadMap.TryRemove(key, out _); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/SearchService.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/SearchService.cs new file mode 100644 index 000000000..afa683e27 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/SearchService.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Identity; +using Azure.Search.Documents; +using Azure.Search.Documents.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Service for vector search operations using Azure AI Search + /// + public class SearchService : ISearchService + { + private readonly IEmbeddingService _embeddingService; + private readonly AzureFoundryOptions _options; + private readonly ILogger _logger; + + public SearchService( + IEmbeddingService embeddingService, + IOptions options, + ILogger logger) + { + _embeddingService = embeddingService ?? throw new ArgumentNullException(nameof(embeddingService)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task SearchAsync( + string indexName, + string query, + int topK = 5, + string filter = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(query)) + throw new ArgumentException("Query cannot be null or empty", nameof(query)); + + _logger.LogDebug("Generating embedding for search query"); + var embeddingResponse = await _embeddingService.GenerateEmbeddingAsync(query, cancellationToken: cancellationToken); + + return await SearchByVectorAsync(indexName, embeddingResponse.Embedding, topK, filter, cancellationToken); + } + + public async Task SearchByVectorAsync( + string indexName, + float[] embedding, + int topK = 5, + string filter = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(indexName)) + throw new ArgumentException("Index name cannot be null or empty", nameof(indexName)); + + if (embedding == null || embedding.Length == 0) + throw new ArgumentException("Embedding cannot be null or empty", nameof(embedding)); + + if (string.IsNullOrEmpty(_options.SearchEndpoint)) + throw new InvalidOperationException("Search endpoint is not configured"); + + var searchClient = CreateSearchClient(indexName); + + var vectorQuery = new VectorizedQuery(embedding.Select(f => f).ToArray()) + { + KNearestNeighborsCount = topK, + Fields = { "contentVector" } + }; + + var searchOptions = new SearchOptions + { + VectorSearch = new VectorSearchOptions + { + Queries = { vectorQuery } + }, + Size = topK, + Select = { "id", "content", "title" } + }; + + if (!string.IsNullOrEmpty(filter)) + { + searchOptions.Filter = filter; + } + + _logger.LogDebug("Executing vector search on index {IndexName}", indexName); + + var response = await searchClient.SearchAsync(null, searchOptions, cancellationToken); + var results = new SearchResults { Query = "vector search" }; + + await foreach (var result in response.Value.GetResultsAsync()) + { + var searchResult = new SearchResult + { + DocumentId = result.Document.GetString("id"), + Score = result.Score ?? 0, + Content = result.Document.GetString("content"), + Title = result.Document.GetString("title") + }; + + foreach (var field in result.Document) + { + if (field.Key != "id" && field.Key != "content" && field.Key != "title" && field.Key != "contentVector") + { + searchResult.Fields[field.Key] = field.Value; + } + } + + results.Results.Add(searchResult); + } + + results.TotalCount = response.Value.TotalCount; + return results; + } + + private SearchClient CreateSearchClient(string indexName) + { + var endpoint = new Uri(_options.SearchEndpoint); + + if (!string.IsNullOrEmpty(_options.SearchApiKey)) + { + return new SearchClient(endpoint, indexName, new AzureKeyCredential(_options.SearchApiKey)); + } + + return new SearchClient(endpoint, indexName, new DefaultAzureCredential()); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ToolRegistry.cs b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ToolRegistry.cs new file mode 100644 index 000000000..ffe113698 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/Services/ToolRegistry.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.AI.AzureFoundry.Services +{ + /// + /// Registry for managing agent tools + /// + public class ToolRegistry : IToolRegistry + { + private readonly ConcurrentDictionary _tools = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary _toolTypes = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly IServiceProvider _serviceProvider; + + public ToolRegistry(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public void Register(IAgentTool tool) + { + if (tool == null) + throw new ArgumentNullException(nameof(tool)); + + if (string.IsNullOrEmpty(tool.Name)) + throw new ArgumentException("Tool name cannot be null or empty", nameof(tool)); + + _tools[tool.Name] = tool; + } + + public void Register() where T : IAgentTool + { + var tool = _serviceProvider.GetRequiredService(); + Register(tool); + _toolTypes[tool.Name] = typeof(T); + } + + public IAgentTool GetTool(string name) + { + if (_tools.TryGetValue(name, out var tool)) + return tool; + + if (_toolTypes.TryGetValue(name, out var type)) + { + tool = (IAgentTool)_serviceProvider.GetRequiredService(type); + _tools[name] = tool; + return tool; + } + + return null; + } + + public IEnumerable GetAllTools() + { + return _tools.Values.ToList(); + } + + public IEnumerable GetToolDefinitions() + { + return _tools.Values.Select(t => new ToolDefinition + { + Name = t.Name, + Description = t.Description, + ParametersSchema = t.ParametersSchema, + ImplementationType = t.GetType() + }).ToList(); + } + + public bool HasTool(string name) + { + return _tools.ContainsKey(name) || _toolTypes.ContainsKey(name); + } + } +} diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/WorkflowCore.AI.AzureFoundry.csproj b/src/extensions/WorkflowCore.AI.AzureFoundry/WorkflowCore.AI.AzureFoundry.csproj new file mode 100644 index 000000000..7f2bc5260 --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/WorkflowCore.AI.AzureFoundry.csproj @@ -0,0 +1,35 @@ + + + + Workflow Core extensions for Azure AI Foundry + Daniel Gerlag + netstandard2.0 + WorkflowCore.AI.AzureFoundry + WorkflowCore.AI.AzureFoundry + workflow;.NET;Core;WorkflowCore;AI;Azure;Foundry;LLM;Agent + https://github.com/danielgerlag/workflow-core + https://github.com/danielgerlag/workflow-core/blob/master/LICENSE.md + git + https://github.com/danielgerlag/workflow-core.git + false + false + false + Provides extensions for Workflow Core to integrate Azure AI Foundry capabilities including LLM invocation, agent orchestration, and agentic workflow activities. + 8.0 + + + + + + + + + + + + + + + + + diff --git a/src/extensions/WorkflowCore.AI.AzureFoundry/readme.md b/src/extensions/WorkflowCore.AI.AzureFoundry/readme.md new file mode 100644 index 000000000..298a7815a --- /dev/null +++ b/src/extensions/WorkflowCore.AI.AzureFoundry/readme.md @@ -0,0 +1,500 @@ +# WorkflowCore.AI.AzureFoundry + +[![NuGet](https://img.shields.io/nuget/v/WorkflowCore.AI.AzureFoundry.svg)](https://www.nuget.org/packages/WorkflowCore.AI.AzureFoundry/) + +Azure AI Foundry extension for [WorkflowCore](https://github.com/danielgerlag/workflow-core) - enables building AI-powered, agentic workflows with LLM invocation, automatic tool execution, embeddings, RAG search, and human-in-the-loop review patterns. + +## Table of Contents + +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Available Steps](#available-steps) + - [ChatCompletion](#chatcompletion) + - [AgentLoop](#agentloop) + - [ExecuteTool](#executetool) + - [GenerateEmbedding](#generateembedding) + - [VectorSearch](#vectorsearch) + - [HumanReview](#humanreview) +- [Creating Custom Tools](#creating-custom-tools) +- [Conversation History](#conversation-history) +- [Authentication](#authentication) +- [Samples](#samples) +- [API Reference](#api-reference) + +## Features + +- **LLM Chat Completion** - Invoke Azure AI models with full conversation history support +- **Agentic Workflows** - Automatic tool-calling loops where the LLM decides which tools to use +- **Tool Execution Framework** - Define and register custom tools that the LLM can invoke +- **Embeddings Generation** - Generate vector embeddings for semantic search and RAG +- **Vector Search** - Integrate with Azure AI Search for similarity search +- **Human-in-the-Loop** - Pause workflows for human review/approval of AI outputs +- **Conversation Persistence** - Automatic conversation history management across workflow steps + +## Installation + +```bash +dotnet add package WorkflowCore.AI.AzureFoundry +``` + +## Quick Start + +```csharp +// 1. Configure services +services.AddWorkflow(); +services.AddAzureFoundry(options => +{ + options.Endpoint = "https://myresource.services.ai.azure.com"; + options.ApiKey = Environment.GetEnvironmentVariable("AZURE_AI_API_KEY"); + options.DefaultModel = "gpt-4o"; +}); + +// 2. Define a workflow with AI steps +public class CustomerSupportWorkflow : IWorkflow +{ + public string Id => "CustomerSupport"; + public int Version => 1; + + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .AgentLoop(cfg => cfg + .SystemPrompt("You are a helpful customer support agent.") + .Message(data => data.CustomerQuery) + .WithTool() + .WithTool() + .MaxIterations(5) + .OutputTo(data => data.Response)); + } +} + +// 3. Run the workflow +var workflowId = await host.StartWorkflow("CustomerSupport", new SupportData +{ + CustomerQuery = "How do I reset my password?" +}); +``` + +## Configuration + +### Basic Configuration + +```csharp +services.AddAzureFoundry(options => +{ + // Required: Azure AI Foundry endpoint + options.Endpoint = "https://myresource.services.ai.azure.com"; + + // Authentication (choose one) + options.ApiKey = "your-api-key"; // API key authentication + // OR + options.Credential = new DefaultAzureCredential(); // Azure AD authentication + + // Model configuration + options.DefaultModel = "gpt-4o"; + options.DefaultEmbeddingModel = "text-embedding-3-small"; + options.DefaultTemperature = 0.7f; + options.DefaultMaxTokens = 4096; + + // Azure AI Search (optional, for RAG) + options.SearchEndpoint = "https://mysearch.search.windows.net"; + options.SearchApiKey = "your-search-api-key"; +}); +``` + +### Environment Variables + +The sample project supports `.env` files: + +```bash +AZURE_AI_ENDPOINT=https://myresource.services.ai.azure.com +AZURE_AI_API_KEY=your-api-key +AZURE_AI_DEFAULT_MODEL=gpt-4o +AZURE_AI_PROJECT=myproject +``` + +## Available Steps + +### ChatCompletion + +Simple LLM chat completion with optional conversation history. + +```csharp +builder + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a helpful assistant") + .UserMessage(data => data.UserQuery) + .Model("gpt-4o") // Optional: override default model + .Temperature(0.7f) // Optional: creativity level (0-1) + .MaxTokens(1000) // Optional: response length limit + .WithHistory() // Optional: enable conversation history + .OutputTo(data => data.Response) + .OutputTokensTo(data => data.TokensUsed)); +``` + +**Inputs:** +| Property | Type | Description | +|----------|------|-------------| +| `SystemPrompt` | string | System message defining assistant behavior | +| `UserMessage` | string | User's message/query | +| `Model` | string | Model to use (optional) | +| `Temperature` | float? | Creativity level 0-1 (optional) | +| `MaxTokens` | int? | Maximum response tokens (optional) | + +**Outputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Response` | string | LLM's response text | +| `TokensUsed` | int | Total tokens consumed | +| `FinishReason` | string | Why generation stopped | + +--- + +### AgentLoop + +Agentic workflow with automatic tool execution. The LLM decides which tools to call, the step executes them, and continues until the LLM provides a final response. + +```csharp +builder + .AgentLoop(cfg => cfg + .SystemPrompt("You are an agent with access to tools") + .Message(data => data.UserRequest) + .WithTool() // Register available tools + .WithTool() + .MaxIterations(10) // Prevent infinite loops + .AutoExecuteTools() // Automatically execute tool calls + .OutputTo(data => data.AgentResponse) + .OutputIterationsTo(data => data.IterationsUsed) + .OutputToolResultsTo(data => data.ToolResults)); +``` + +**Inputs:** +| Property | Type | Description | +|----------|------|-------------| +| `SystemPrompt` | string | Agent behavior definition | +| `UserMessage` | string | User's request | +| `MaxIterations` | int | Maximum LLM calls (default: 10) | +| `AutomaticMode` | bool | Auto-execute tools (default: true) | +| `AvailableTools` | IList | Tool names to use (empty = all) | + +**Outputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Response` | string | Final agent response | +| `IterationsExecuted` | int | Number of LLM calls made | +| `ToolResults` | IList | Results from tool executions | +| `CompletedSuccessfully` | bool | True if completed before max iterations | + +--- + +### ExecuteTool + +Manually execute a specific tool (useful for non-automatic tool orchestration). + +```csharp +builder + .ExecuteTool(cfg => cfg + .Input(s => s.ToolName, data => "weather") + .Input(s => s.Arguments, data => JsonSerializer.Serialize(new { city = data.City })) + .Output(s => s.Result, data => data.ToolOutput)); +``` + +--- + +### GenerateEmbedding + +Generate vector embeddings for semantic similarity and RAG applications. + +```csharp +builder + .GenerateEmbedding(cfg => cfg + .Input(s => s.Text, data => data.ContentToEmbed) + .Model("text-embedding-3-small") // Optional: override model + .Output(s => s.Embedding, data => data.EmbeddingVector) + .Output(s => s.TokensUsed, data => data.EmbeddingTokens)); +``` + +**Inputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Text` | string | Text to generate embedding for | +| `Model` | string | Embedding model (optional) | + +**Outputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Embedding` | float[] | Vector embedding array | +| `TokensUsed` | int | Tokens consumed | + +--- + +### VectorSearch + +Search using vector similarity with Azure AI Search. + +```csharp +builder + .VectorSearch(cfg => cfg + .Input(s => s.Query, data => data.SearchQuery) + .Input(s => s.IndexName, data => "knowledge-base") + .Input(s => s.TopK, data => 5) + .Input(s => s.Filter, data => "category eq 'support'") // OData filter + .Output(s => s.Results, data => data.SearchResults)); +``` + +**Inputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Query` | string | Search query text | +| `IndexName` | string | Azure AI Search index name | +| `TopK` | int | Number of results to return | +| `Filter` | string | OData filter expression (optional) | + +**Outputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Results` | IList | Matching documents with scores | + +--- + +### HumanReview + +Pause workflow for human review, approval, or modification of AI-generated content. + +```csharp +builder + .HumanReview(cfg => cfg + .Content(data => data.AIGeneratedContent) + .Reviewer(data => data.AssignedReviewer) + .Prompt("Please review this AI-generated response before sending to customer") + .CorrelationId(data => data.TicketId) // Optional: custom event key + .OnEventKey(data => data.ReviewEventKey) // Optional: capture the event key + .OnApproved(data => data.ApprovedContent) + .OutputDecisionTo(data => data.ReviewDecision)); +``` + +**Inputs:** +| Property | Type | Description | +|----------|------|-------------| +| `Content` | string | The content to be reviewed | +| `Reviewer` | string | Assigned reviewer identifier | +| `ReviewPrompt` | string | Instructions for the reviewer | +| `CorrelationId` | string | Custom event key (optional, defaults to workflowId) | + +**Outputs:** +| Property | Type | Description | +|----------|------|-------------| +| `EventKey` | string | The key to use when completing the review | +| `ApprovedContent` | string | Final approved/modified content | +| `Decision` | ReviewDecision | The reviewer's decision | +| `IsApproved` | bool | Whether content was approved | +| `Comments` | string | Reviewer's comments | + +**Getting the Event Key:** + +There are three ways to get the event key for completing a review: + +1. **Use the workflow ID** (default): If you don't provide a `CorrelationId`, the event key equals the workflow ID +2. **Use a custom correlation ID**: Provide your own ID via `.CorrelationId(data => data.MyId)` +3. **Capture the event key**: Use `.OnEventKey(data => data.ReviewEventKey)` to store it in workflow data + +**Complete a review by publishing an event:** + +```csharp +// Option 1: Use workflow ID (when no CorrelationId was set) +await workflowHost.PublishEvent("HumanReview", workflowId, reviewAction); + +// Option 2: Use your custom correlation ID +await workflowHost.PublishEvent("HumanReview", "TICKET-12345", reviewAction); + +// Option 3: Use the captured event key from workflow data +await workflowHost.PublishEvent("HumanReview", data.ReviewEventKey, reviewAction); +``` + +```csharp +var reviewAction = new ReviewAction +{ + Decision = ReviewDecision.Approved, // or Rejected, ApprovedWithChanges + Reviewer = "john.doe@example.com", + ModifiedContent = "Updated content...", // if modified + Comments = "Looks good!" +}; +``` + +## Creating Custom Tools + +Tools allow the LLM to take actions in your system. Implement `IAgentTool`: + +```csharp +public class WeatherTool : IAgentTool +{ + public string Name => "weather"; + + public string Description => "Get current weather for a city"; + + public string ParametersSchema => @"{ + ""type"": ""object"", + ""properties"": { + ""city"": { + ""type"": ""string"", + ""description"": ""City name"" + } + }, + ""required"": [""city""] + }"; + + private readonly IWeatherService _weatherService; + + public WeatherTool(IWeatherService weatherService) + { + _weatherService = weatherService; + } + + public async Task ExecuteAsync( + string toolCallId, + string arguments, + CancellationToken cancellationToken) + { + try + { + var args = JsonSerializer.Deserialize(arguments); + var weather = await _weatherService.GetWeatherAsync(args.City, cancellationToken); + + return ToolResult.Succeeded(toolCallId, Name, JsonSerializer.Serialize(weather)); + } + catch (Exception ex) + { + return ToolResult.Failed(toolCallId, Name, ex.Message); + } + } +} +``` + +**Register tools in DI:** + +```csharp +// Register tool class +services.AddSingleton(); +services.AddSingleton(); + +// Register with tool registry +var toolRegistry = serviceProvider.GetRequiredService(); +toolRegistry.Register(serviceProvider.GetRequiredService()); +toolRegistry.Register(serviceProvider.GetRequiredService()); +``` + +## Conversation History + +Conversation history is automatically managed per workflow execution using `IConversationStore`. + +### Default In-Memory Store + +```csharp +// Enabled by default - conversations stored in memory +services.AddAzureFoundry(options => { ... }); +``` + +### Custom Store Implementation + +Implement `IConversationStore` for persistent storage (Redis, SQL, CosmosDB, etc.): + +```csharp +public class RedisConversationStore : IConversationStore +{ + public Task GetOrCreateThreadAsync( + string workflowId, string stepId) { ... } + + public Task GetThreadAsync(string threadId) { ... } + + public Task SaveThreadAsync(ConversationThread thread) { ... } + + public Task DeleteThreadAsync(string threadId) { ... } +} + +// Register custom store +services.AddSingleton(); +``` + +## Authentication + +### API Key Authentication (Simplest) + +```csharp +services.AddAzureFoundry(options => +{ + options.Endpoint = "https://myresource.services.ai.azure.com"; + options.ApiKey = Environment.GetEnvironmentVariable("AZURE_AI_API_KEY"); +}); +``` + +### Azure AD Authentication + +```csharp +services.AddAzureFoundry(options => +{ + options.Endpoint = "https://myresource.services.ai.azure.com"; + options.Credential = new DefaultAzureCredential(); + + // Or specific credential types: + // options.Credential = new ManagedIdentityCredential(); + // options.Credential = new ClientSecretCredential(tenantId, clientId, secret); +}); +``` + +## Samples + +See the [sample project](../../samples/WorkflowCore.Sample.AzureFoundry/) for complete working examples: + +| Sample | Description | +|--------|-------------| +| **Simple Chat** | Basic LLM chat completion workflow | +| **Agent with Tools** | Agentic workflow with weather and calculator tools | +| **Human Review** | Human-in-the-loop approval workflow | + +### Running the Sample + +```bash +cd src/samples/WorkflowCore.Sample.AzureFoundry +cp .env.example .env +# Edit .env with your Azure AI credentials +dotnet run +``` + +## API Reference + +### Models + +| Class | Description | +|-------|-------------| +| `AzureFoundryOptions` | Configuration options for the extension | +| `ConversationMessage` | A single message in a conversation | +| `ConversationThread` | A conversation thread with message history | +| `ToolDefinition` | Defines a tool's name, description, and parameters | +| `ToolResult` | Result from tool execution | +| `SearchResult` | A single search result with score and content | +| `ReviewAction` | Human review decision and modifications | + +### Interfaces + +| Interface | Description | +|-----------|-------------| +| `IChatCompletionService` | Service for LLM chat completions | +| `IEmbeddingService` | Service for generating embeddings | +| `ISearchService` | Service for vector search | +| `IAgentTool` | Interface for custom tools | +| `IToolRegistry` | Registry for available tools | +| `IConversationStore` | Storage for conversation history | + +### Enums + +| Enum | Values | +|------|--------| +| `MessageRole` | System, User, Assistant, Tool | +| `ReviewDecision` | Pending, Approved, Rejected, Modified | + +## License + +This extension is part of WorkflowCore and is released under the [MIT License](../../LICENSE.md). diff --git a/src/providers/WorkflowCore.LockProviders.SqlServer/SqlLockProvider.cs b/src/providers/WorkflowCore.LockProviders.SqlServer/SqlLockProvider.cs index 92795d871..8bb0cf5dc 100644 --- a/src/providers/WorkflowCore.LockProviders.SqlServer/SqlLockProvider.cs +++ b/src/providers/WorkflowCore.LockProviders.SqlServer/SqlLockProvider.cs @@ -1,5 +1,5 @@ using System; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using WorkflowCore.Interface; diff --git a/src/providers/WorkflowCore.LockProviders.SqlServer/WorkflowCore.LockProviders.SqlServer.csproj b/src/providers/WorkflowCore.LockProviders.SqlServer/WorkflowCore.LockProviders.SqlServer.csproj index 5c798129a..4d9702579 100644 --- a/src/providers/WorkflowCore.LockProviders.SqlServer/WorkflowCore.LockProviders.SqlServer.csproj +++ b/src/providers/WorkflowCore.LockProviders.SqlServer/WorkflowCore.LockProviders.SqlServer.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/providers/WorkflowCore.Persistence.EntityFramework/ExtensionMethods.cs b/src/providers/WorkflowCore.Persistence.EntityFramework/ExtensionMethods.cs index b0b2b1d99..536ccc7be 100644 --- a/src/providers/WorkflowCore.Persistence.EntityFramework/ExtensionMethods.cs +++ b/src/providers/WorkflowCore.Persistence.EntityFramework/ExtensionMethods.cs @@ -9,12 +9,16 @@ namespace WorkflowCore.Persistence.EntityFramework { internal static class ExtensionMethods { - private static JsonSerializerSettings SerializerSettings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }; + private static JsonSerializerSettings SerializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.All, + ObjectCreationHandling = ObjectCreationHandling.Replace + }; internal static PersistedWorkflow ToPersistable(this WorkflowInstance instance, PersistedWorkflow persistable = null) { - if (persistable == null) - persistable = new PersistedWorkflow(); + if (persistable == null) + persistable = new PersistedWorkflow(); persistable.Data = JsonConvert.SerializeObject(instance.Data, SerializerSettings); persistable.Description = instance.Description; @@ -25,19 +29,19 @@ internal static PersistedWorkflow ToPersistable(this WorkflowInstance instance, persistable.WorkflowDefinitionId = instance.WorkflowDefinitionId; persistable.Status = instance.Status; persistable.CreateTime = instance.CreateTime; - persistable.CompleteTime = instance.CompleteTime; - + persistable.CompleteTime = instance.CompleteTime; + foreach (var ep in instance.ExecutionPointers) { var persistedEP = persistable.ExecutionPointers.FindById(ep.Id); - + if (persistedEP == null) { persistedEP = new PersistedExecutionPointer(); persistedEP.Id = ep.Id ?? Guid.NewGuid().ToString(); persistable.ExecutionPointers.Add(persistedEP); - } - + } + persistedEP.StepId = ep.StepId; persistedEP.Active = ep.Active; persistedEP.SleepUntil = ep.SleepUntil; @@ -83,7 +87,7 @@ internal static PersistedWorkflow ToPersistable(this WorkflowInstance instance, internal static PersistedExecutionError ToPersistable(this ExecutionError instance) { - var result = new PersistedExecutionError(); + var result = new PersistedExecutionError(); result.ErrorTime = instance.ErrorTime; result.Message = instance.Message; result.ExecutionPointerId = instance.ExecutionPointerId; @@ -94,7 +98,7 @@ internal static PersistedExecutionError ToPersistable(this ExecutionError instan internal static PersistedSubscription ToPersistable(this EventSubscription instance) { - PersistedSubscription result = new PersistedSubscription(); + PersistedSubscription result = new PersistedSubscription(); result.SubscriptionId = new Guid(instance.Id); result.EventKey = instance.EventKey; result.EventName = instance.EventName; @@ -106,7 +110,7 @@ internal static PersistedSubscription ToPersistable(this EventSubscription insta result.ExternalToken = instance.ExternalToken; result.ExternalTokenExpiry = instance.ExternalTokenExpiry; result.ExternalWorkerId = instance.ExternalWorkerId; - + return result; } @@ -152,7 +156,7 @@ internal static WorkflowInstance ToWorkflowInstance(this PersistedWorkflow insta foreach (var ep in instance.ExecutionPointers) { - var pointer = new ExecutionPointer(); + var pointer = new ExecutionPointer(); pointer.Id = ep.Id; pointer.StepId = ep.StepId; diff --git a/src/providers/WorkflowCore.Persistence.EntityFramework/Services/LargeDataOptimizedEntityFrameworkPersistenceProvider.cs b/src/providers/WorkflowCore.Persistence.EntityFramework/Services/LargeDataOptimizedEntityFrameworkPersistenceProvider.cs new file mode 100644 index 000000000..c465a68e1 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.EntityFramework/Services/LargeDataOptimizedEntityFrameworkPersistenceProvider.cs @@ -0,0 +1,135 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using WorkflowCore.Persistence.EntityFramework.Models; +using WorkflowCore.Models; +using WorkflowCore.Persistence.EntityFramework.Interfaces; +using System.Threading; +using WorkflowCore.Interface; + +namespace WorkflowCore.Persistence.EntityFramework.Services +{ + public sealed class LargeDataOptimizedEntityFrameworkPersistenceProvider : EntityFrameworkPersistenceProvider, IPersistenceProvider + { + private readonly IWorkflowDbContextFactory _contextFactory; + + public LargeDataOptimizedEntityFrameworkPersistenceProvider(IWorkflowDbContextFactory contextFactory, bool canCreateDb, bool canMigrateDb) + : base(contextFactory, canCreateDb, canMigrateDb) + { + _contextFactory = contextFactory; + } + + /// + public new async Task> GetWorkflowInstances(WorkflowStatus? status, string type, DateTime? createdFrom, DateTime? createdTo, int skip, int take) + { + using (var db = _contextFactory.Build()) + { + IQueryable query = db.Set() + .Include(wf => wf.ExecutionPointers) + .ThenInclude(ep => ep.ExtensionAttributes) + .Include(wf => wf.ExecutionPointers) + .AsSplitQuery() + .AsQueryable(); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (!string.IsNullOrEmpty(type)) + { + query = query.Where(x => x.WorkflowDefinitionId == type); + } + + if (createdFrom.HasValue) + { + query = query.Where(x => x.CreateTime >= createdFrom.Value); + } + + if (createdTo.HasValue) + { + query = query.Where(x => x.CreateTime <= createdTo.Value); + } + + var rawResult = await query.OrderBy(x => x.PersistenceId).Skip(skip).Take(take).ToListAsync(); + + var result = new List(rawResult.Count); + + foreach (var item in rawResult) + { + result.Add(item.ToWorkflowInstance()); + } + + return result; + } + } + + /// + public new async Task GetWorkflowInstance(string id, CancellationToken cancellationToken = default) + { + using (var db = _contextFactory.Build()) + { + var uid = new Guid(id); + var raw = await db.Set() + .Include(wf => wf.ExecutionPointers) + .ThenInclude(ep => ep.ExtensionAttributes) + .Include(wf => wf.ExecutionPointers) + .AsSplitQuery() + .FirstAsync(x => x.InstanceId == uid, cancellationToken); + + return raw?.ToWorkflowInstance(); + } + } + + /// + public new async Task> GetWorkflowInstances(IEnumerable ids, CancellationToken cancellationToken = default) + { + if (ids == null) + { + return Array.Empty(); + } + + using (var db = _contextFactory.Build()) + { + var uids = ids.Select(i => new Guid(i)); + var raw = db.Set() + .Include(wf => wf.ExecutionPointers) + .ThenInclude(ep => ep.ExtensionAttributes) + .Include(wf => wf.ExecutionPointers) + .AsSplitQuery() + .Where(x => uids.Contains(x.InstanceId)); + + var persistedWorkflows = await raw.ToListAsync(cancellationToken); + + return persistedWorkflows.Select(i => i.ToWorkflowInstance()); + } + } + + /// + public new async Task PersistWorkflow(WorkflowInstance workflow, CancellationToken cancellationToken = default) + { + using (var db = _contextFactory.Build()) + using (var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken)) + { + var uid = new Guid(workflow.Id); + var existingEntity = await db.Set() + .Where(x => x.InstanceId == uid) + .Include(wf => wf.ExecutionPointers) + .ThenInclude(ep => ep.ExtensionAttributes) + .Include(wf => wf.ExecutionPointers) + .AsSplitQuery() + .AsTracking() + .FirstAsync(cancellationToken); + + _ = workflow.ToPersistable(existingEntity); + + await db.SaveChangesAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/src/providers/WorkflowCore.Persistence.EntityFramework/WorkflowCore.Persistence.EntityFramework.csproj b/src/providers/WorkflowCore.Persistence.EntityFramework/WorkflowCore.Persistence.EntityFramework.csproj index 6ab835915..f91864403 100644 --- a/src/providers/WorkflowCore.Persistence.EntityFramework/WorkflowCore.Persistence.EntityFramework.csproj +++ b/src/providers/WorkflowCore.Persistence.EntityFramework/WorkflowCore.Persistence.EntityFramework.csproj @@ -3,14 +3,14 @@ Workflow Core EntityFramework Core Persistence Provider Daniel Gerlag - netstandard2.1;net6.0 + netstandard2.1;net6.0;net8.0 WorkflowCore.Persistence.EntityFramework WorkflowCore.Persistence.EntityFramework workflow;.NET;Core;state machine;WorkflowCore;EntityFramework;EntityFrameworkCore https://github.com/danielgerlag/workflow-core https://github.com/danielgerlag/workflow-core/blob/master/LICENSE.md git - https://github.com/danielgerlag/workflow-core.git + https://github.com/danielgerlag/workflow-core.git false false false @@ -22,12 +22,16 @@ - + + + + + - - + + diff --git a/src/providers/WorkflowCore.Persistence.MongoDB/README.md b/src/providers/WorkflowCore.Persistence.MongoDB/README.md index 911d8a9e4..4fd8413d5 100644 --- a/src/providers/WorkflowCore.Persistence.MongoDB/README.md +++ b/src/providers/WorkflowCore.Persistence.MongoDB/README.md @@ -18,13 +18,50 @@ Use the .UseMongoDB extension method when building your service provider. services.AddWorkflow(x => x.UseMongoDB(@"mongodb://localhost:27017", "workflow")); ``` +### Configuring the ObjectSerializer + +When using MongoDB persistence with user-defined data classes, you need to configure which types are allowed to be deserialized. This is done via the `serializerTypeFilter` parameter: + +```C# +services.AddWorkflow(x => x.UseMongoDB( + @"mongodb://localhost:27017", + "workflow", + serializerTypeFilter: type => + MongoDB.Bson.Serialization.Serializers.ObjectSerializer.DefaultAllowedTypes(type) || + type.FullName?.StartsWith("MyApp.") == true)); +``` + +This configuration allows: +- All default MongoDB allowed types (primitives, collections, etc.) +- Types in your application namespace (e.g., `MyApp.*`) + +**Important:** You must configure the serializer to allow your workflow data types, otherwise you will encounter a `BsonSerializationException` when MongoDB tries to deserialize your data. + +Example for multiple namespaces: + +```C# +services.AddWorkflow(x => x.UseMongoDB( + @"mongodb://localhost:27017", + "workflow", + serializerTypeFilter: type => + { + if (MongoDB.Bson.Serialization.Serializers.ObjectSerializer.DefaultAllowedTypes(type)) + return true; + + var fullName = type.FullName ?? ""; + return fullName.StartsWith("MyApp.") || + fullName.StartsWith("MyCompany.Models.") || + fullName.StartsWith("WorkflowCore."); + })); +``` + ### State object serialization By default (to maintain backwards compatibility), the state object is serialized using a two step serialization process using object -> JSON -> BSON serialization. This approach has some limitations, for example you cannot control which types will be used in MongoDB for particular fields and you cannot use basic types that are not present in JSON (decimal, timestamp, etc). To eliminate these limitations, you can use a direct object -> BSON serialization and utilize all serialization possibilities that MongoDb driver provides. You can read more in the [MongoDb CSharp documentation](https://mongodb.github.io/mongo-csharp-driver/1.11/serialization/). -To enable direct serilization you need to register a class map for you state class somewhere in your startup process before you run `WorkflowHost`. +To enable direct serialization you need to register a class map for you state class somewhere in your startup process before you run `WorkflowHost`. ```C# private void RunWorkflow() diff --git a/src/providers/WorkflowCore.Persistence.MongoDB/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Persistence.MongoDB/ServiceCollectionExtensions.cs index 9534fe0b2..f03205c3a 100644 --- a/src/providers/WorkflowCore.Persistence.MongoDB/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.Persistence.MongoDB/ServiceCollectionExtensions.cs @@ -12,8 +12,11 @@ public static WorkflowOptions UseMongoDB( this WorkflowOptions options, string mongoUrl, string databaseName, - Action configureClient = default) + Action configureClient = default, + Func serializerTypeFilter = null) { + RegisterObjectSerializer(serializerTypeFilter); + options.UsePersistence(sp => { var mongoClientSettings = MongoClientSettings.FromConnectionString(mongoUrl); @@ -35,11 +38,14 @@ public static WorkflowOptions UseMongoDB( public static WorkflowOptions UseMongoDB( this WorkflowOptions options, - Func createDatabase) + Func createDatabase, + Func serializerTypeFilter = null) { if (options == null) throw new ArgumentNullException(nameof(options)); if (createDatabase == null) throw new ArgumentNullException(nameof(createDatabase)); + RegisterObjectSerializer(serializerTypeFilter); + options.UsePersistence(sp => { var db = createDatabase(sp); @@ -53,5 +59,14 @@ public static WorkflowOptions UseMongoDB( return options; } + + private static void RegisterObjectSerializer(Func serializerTypeFilter) + { + if (serializerTypeFilter != null) + { + MongoDB.Bson.Serialization.BsonSerializer.TryRegisterSerializer( + new MongoDB.Bson.Serialization.Serializers.ObjectSerializer(serializerTypeFilter)); + } + } } } diff --git a/src/providers/WorkflowCore.Persistence.MongoDB/Services/MongoPersistenceProvider.cs b/src/providers/WorkflowCore.Persistence.MongoDB/Services/MongoPersistenceProvider.cs index fdab92cf6..a72340d68 100644 --- a/src/providers/WorkflowCore.Persistence.MongoDB/Services/MongoPersistenceProvider.cs +++ b/src/providers/WorkflowCore.Persistence.MongoDB/Services/MongoPersistenceProvider.cs @@ -23,7 +23,6 @@ public class MongoPersistenceProvider : IPersistenceProvider public MongoPersistenceProvider(IMongoDatabase database) { _database = database; - CreateIndexes(this); } static MongoPersistenceProvider() @@ -198,7 +197,7 @@ public async Task> GetWorkflowInstances(IEnumerabl public async Task> GetWorkflowInstances(WorkflowStatus? status, string type, DateTime? createdFrom, DateTime? createdTo, int skip, int take) { - IMongoQueryable result = WorkflowInstances.AsQueryable(); + IQueryable result = WorkflowInstances.AsQueryable(); if (status.HasValue) result = result.Where(x => x.Status == status.Value); @@ -263,7 +262,7 @@ public async Task ClearSubscriptionToken(string eventSubscriptionId, string toke public void EnsureStoreExists() { - + CreateIndexes(this); } public async Task> GetSubscriptions(string eventName, string eventKey, DateTime asOf, CancellationToken cancellationToken = default) diff --git a/src/providers/WorkflowCore.Persistence.MongoDB/WorkflowCore.Persistence.MongoDB.csproj b/src/providers/WorkflowCore.Persistence.MongoDB/WorkflowCore.Persistence.MongoDB.csproj index 5d395eee9..5dfdf800f 100644 --- a/src/providers/WorkflowCore.Persistence.MongoDB/WorkflowCore.Persistence.MongoDB.csproj +++ b/src/providers/WorkflowCore.Persistence.MongoDB/WorkflowCore.Persistence.MongoDB.csproj @@ -3,7 +3,7 @@ Workflow Core MongoDB Persistence Provider Daniel Gerlag - netstandard2.0 + netstandard2.1 WorkflowCore.Persistence.MongoDB WorkflowCore.Persistence.MongoDB workflow;.NET;Core;state machine;WorkflowCore;MongoDB;Mongo @@ -22,7 +22,7 @@ - + diff --git a/src/providers/WorkflowCore.Persistence.MySQL/Migrations/MysqlPersistenceProviderModelSnapshot.cs b/src/providers/WorkflowCore.Persistence.MySQL/Migrations/MysqlPersistenceProviderModelSnapshot.cs index 8a3e02e9b..59d48fac7 100644 --- a/src/providers/WorkflowCore.Persistence.MySQL/Migrations/MysqlPersistenceProviderModelSnapshot.cs +++ b/src/providers/WorkflowCore.Persistence.MySQL/Migrations/MysqlPersistenceProviderModelSnapshot.cs @@ -15,7 +15,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Relational:MaxIdentifierLength", 64) - .HasAnnotation("ProductVersion", "5.0.8"); +#if NETSTANDARD2_1 + .HasAnnotation("ProductVersion", "5.0.1") +#elif NET6_0 + .HasAnnotation("ProductVersion", "7.0.0") +#elif NET8_0 + .HasAnnotation("ProductVersion", "9.0.9") +#else + .HasAnnotation("ProductVersion", "9.0.9") +#endif + ; modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => { diff --git a/src/providers/WorkflowCore.Persistence.MySQL/WorkflowCore.Persistence.MySQL.csproj b/src/providers/WorkflowCore.Persistence.MySQL/WorkflowCore.Persistence.MySQL.csproj index aaf1f79b0..c0fc07851 100644 --- a/src/providers/WorkflowCore.Persistence.MySQL/WorkflowCore.Persistence.MySQL.csproj +++ b/src/providers/WorkflowCore.Persistence.MySQL/WorkflowCore.Persistence.MySQL.csproj @@ -4,7 +4,7 @@ Workflow Core MySQL Persistence Provider 1.0.0 Daniel Gerlag - netstandard2.1;net6.0 + netstandard2.1;net6.0;net8.0 WorkflowCore.Persistence.MySQL WorkflowCore.Persistence.MySQL workflow;.NET;Core;state machine;WorkflowCore;MySQL @@ -35,11 +35,19 @@ - + all runtime; build; native; contentfiles; analyzers - + + + + + + all + runtime; build; native; contentfiles; analyzers + + diff --git a/src/providers/WorkflowCore.Persistence.Oracle/MigrationContextFactory.cs b/src/providers/WorkflowCore.Persistence.Oracle/MigrationContextFactory.cs new file mode 100644 index 000000000..f5a37a5f9 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/MigrationContextFactory.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore.Design; + +namespace WorkflowCore.Persistence.Oracle +{ + public class MigrationContextFactory : IDesignTimeDbContextFactory + { + public OracleContext CreateDbContext(string[] args) + { + return new OracleContext(@"Server=127.0.0.1;Database=myDataBase;Uid=myUsername;Pwd=myPassword;"); + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.Designer.cs b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.Designer.cs new file mode 100644 index 000000000..7948bc948 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.Designer.cs @@ -0,0 +1,377 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Oracle.EntityFrameworkCore.Metadata; +using WorkflowCore.Persistence.Oracle; + +#nullable disable + +namespace WorkflowCore.Persistence.Oracle.Migrations +{ + [DbContext(typeof(OracleContext))] + [Migration("20230310125506_InitialDatabase")] + partial class InitialDatabase + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("EventData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EventId") + .HasColumnType("RAW(16)"); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("IsProcessed") + .HasColumnType("NUMBER(1)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventId") + .IsUnique(); + + b.HasIndex("EventTime"); + + b.HasIndex("IsProcessed"); + + b.HasIndex("EventName", "EventKey"); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionError", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("ErrorTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("ExecutionPointerId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("Message") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("WorkflowId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.HasKey("PersistenceId"); + + b.ToTable("ExecutionError", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("Active") + .HasColumnType("NUMBER(1)"); + + b.Property("Children") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("ContextItem") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EndTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("EventData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EventKey") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("EventName") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("EventPublished") + .HasColumnType("NUMBER(1)"); + + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("NVARCHAR2(50)"); + + b.Property("Outcome") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("PersistenceData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("PredecessorId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("RetryCount") + .HasColumnType("NUMBER(10)"); + + b.Property("Scope") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("SleepUntil") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("StartTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("Status") + .HasColumnType("NUMBER(10)"); + + b.Property("StepId") + .HasColumnType("NUMBER(10)"); + + b.Property("StepName") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("WorkflowId") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("ExecutionPointer", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("AttributeKey") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("AttributeValue") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("ExecutionPointerId") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecutionPointerId"); + + b.ToTable("ExtensionAttribute", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedScheduledCommand", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("CommandName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Data") + .HasMaxLength(500) + .HasColumnType("NVARCHAR2(500)"); + + b.Property("ExecuteTime") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecuteTime"); + + b.HasIndex("CommandName", "Data") + .IsUnique() + .HasFilter("\"CommandName\" IS NOT NULL AND \"Data\" IS NOT NULL"); + + b.ToTable("ScheduledCommand", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedSubscription", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExecutionPointerId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExternalToken") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExternalTokenExpiry") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("ExternalWorkerId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("StepId") + .HasColumnType("NUMBER(10)"); + + b.Property("SubscribeAsOf") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("SubscriptionData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("SubscriptionId") + .HasMaxLength(200) + .HasColumnType("RAW(16)"); + + b.Property("WorkflowId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventKey"); + + b.HasIndex("EventName"); + + b.HasIndex("SubscriptionId") + .IsUnique(); + + b.ToTable("Subscription", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("CompleteTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("CreateTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("Data") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("NVARCHAR2(500)"); + + b.Property("InstanceId") + .HasMaxLength(200) + .HasColumnType("RAW(16)"); + + b.Property("NextExecution") + .HasColumnType("NUMBER(19)"); + + b.Property("Reference") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Status") + .HasColumnType("NUMBER(10)"); + + b.Property("Version") + .HasColumnType("NUMBER(10)"); + + b.Property("WorkflowDefinitionId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.HasIndex("NextExecution"); + + b.ToTable("Workflow", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", "Workflow") + .WithMany("ExecutionPointers") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", "ExecutionPointer") + .WithMany("ExtensionAttributes") + .HasForeignKey("ExecutionPointerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExecutionPointer"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Navigation("ExtensionAttributes"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Navigation("ExecutionPointers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.cs b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.cs new file mode 100644 index 000000000..e758e161d --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/20230310125506_InitialDatabase.cs @@ -0,0 +1,260 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorkflowCore.Persistence.Oracle.Migrations +{ + public partial class InitialDatabase : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Event", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + EventId = table.Column(type: "RAW(16)", nullable: false), + EventName = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + EventKey = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + EventData = table.Column(type: "NVARCHAR2(2000)", nullable: true), + EventTime = table.Column(type: "TIMESTAMP(7)", nullable: false), + IsProcessed = table.Column(type: "NUMBER(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Event", x => x.PersistenceId); + }); + + migrationBuilder.CreateTable( + name: "ExecutionError", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + WorkflowId = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + ExecutionPointerId = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + ErrorTime = table.Column(type: "TIMESTAMP(7)", nullable: false), + Message = table.Column(type: "NVARCHAR2(2000)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExecutionError", x => x.PersistenceId); + }); + + migrationBuilder.CreateTable( + name: "ScheduledCommand", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + CommandName = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + Data = table.Column(type: "NVARCHAR2(500)", maxLength: 500, nullable: true), + ExecuteTime = table.Column(type: "NUMBER(19)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduledCommand", x => x.PersistenceId); + }); + + migrationBuilder.CreateTable( + name: "Subscription", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + SubscriptionId = table.Column(type: "RAW(16)", maxLength: 200, nullable: false), + WorkflowId = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + StepId = table.Column(type: "NUMBER(10)", nullable: false), + ExecutionPointerId = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + EventName = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + EventKey = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + SubscribeAsOf = table.Column(type: "TIMESTAMP(7)", nullable: false), + SubscriptionData = table.Column(type: "NVARCHAR2(2000)", nullable: true), + ExternalToken = table.Column(type: "NVARCHAR2(400)", maxLength: 400, nullable: true), + ExternalWorkerId = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + ExternalTokenExpiry = table.Column(type: "TIMESTAMP(7)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscription", x => x.PersistenceId); + }); + + migrationBuilder.CreateTable( + name: "Workflow", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + InstanceId = table.Column(type: "RAW(16)", maxLength: 200, nullable: false), + WorkflowDefinitionId = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + Version = table.Column(type: "NUMBER(10)", nullable: false), + Description = table.Column(type: "NVARCHAR2(500)", maxLength: 500, nullable: true), + Reference = table.Column(type: "NVARCHAR2(200)", maxLength: 200, nullable: true), + NextExecution = table.Column(type: "NUMBER(19)", nullable: true), + Data = table.Column(type: "CLOB", nullable: true), + CreateTime = table.Column(type: "TIMESTAMP(7)", nullable: false), + CompleteTime = table.Column(type: "TIMESTAMP(7)", nullable: true), + Status = table.Column(type: "NUMBER(10)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Workflow", x => x.PersistenceId); + }); + + migrationBuilder.CreateTable( + name: "ExecutionPointer", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + WorkflowId = table.Column(type: "NUMBER(19)", nullable: false), + Id = table.Column(type: "NVARCHAR2(50)", maxLength: 50, nullable: true), + StepId = table.Column(type: "NUMBER(10)", nullable: false), + Active = table.Column(type: "NUMBER(1)", nullable: false), + SleepUntil = table.Column(type: "TIMESTAMP(7)", nullable: true), + PersistenceData = table.Column(type: "NVARCHAR2(2000)", nullable: true), + StartTime = table.Column(type: "TIMESTAMP(7)", nullable: true), + EndTime = table.Column(type: "TIMESTAMP(7)", nullable: true), + EventName = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + EventKey = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + EventPublished = table.Column(type: "NUMBER(1)", nullable: false), + EventData = table.Column(type: "NVARCHAR2(2000)", nullable: true), + StepName = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + RetryCount = table.Column(type: "NUMBER(10)", nullable: false), + Children = table.Column(type: "NVARCHAR2(2000)", nullable: true), + ContextItem = table.Column(type: "NVARCHAR2(2000)", nullable: true), + PredecessorId = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + Outcome = table.Column(type: "NVARCHAR2(2000)", nullable: true), + Status = table.Column(type: "NUMBER(10)", nullable: false), + Scope = table.Column(type: "NVARCHAR2(2000)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExecutionPointer", x => x.PersistenceId); + table.ForeignKey( + name: "FK_ExecutionPointer_Wf_WfId", + column: x => x.WorkflowId, + principalTable: "Workflow", + principalColumn: "PersistenceId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ExtensionAttribute", + columns: table => new + { + PersistenceId = table.Column(type: "NUMBER(19)", nullable: false) + .Annotation("Oracle:Identity", "START WITH 1 INCREMENT BY 1"), + ExecutionPointerId = table.Column(type: "NUMBER(19)", nullable: false), + AttributeKey = table.Column(type: "NVARCHAR2(100)", maxLength: 100, nullable: true), + AttributeValue = table.Column(type: "NVARCHAR2(2000)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExtensionAttribute", x => x.PersistenceId); + table.ForeignKey( + name: "FK_ExtAttr_ExPtr_ExPtrId", + column: x => x.ExecutionPointerId, + principalTable: "ExecutionPointer", + principalColumn: "PersistenceId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Event_EventId", + table: "Event", + column: "EventId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Event_EventName_EventKey", + table: "Event", + columns: new[] { "EventName", "EventKey" }); + + migrationBuilder.CreateIndex( + name: "IX_Event_EventTime", + table: "Event", + column: "EventTime"); + + migrationBuilder.CreateIndex( + name: "IX_Event_IsProcessed", + table: "Event", + column: "IsProcessed"); + + migrationBuilder.CreateIndex( + name: "IX_ExecutionPointer_WorkflowId", + table: "ExecutionPointer", + column: "WorkflowId"); + + migrationBuilder.CreateIndex( + name: "IX_ExtensionAttribute_ExecutionPointerId", + table: "ExtensionAttribute", + column: "ExecutionPointerId"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledCommand_CommandName_Data", + table: "ScheduledCommand", + columns: new[] { "CommandName", "Data" }, + unique: true, + filter: "\"CommandName\" IS NOT NULL AND \"Data\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledCommand_ExecuteTime", + table: "ScheduledCommand", + column: "ExecuteTime"); + + migrationBuilder.CreateIndex( + name: "IX_Subscription_EventKey", + table: "Subscription", + column: "EventKey"); + + migrationBuilder.CreateIndex( + name: "IX_Subscription_EventName", + table: "Subscription", + column: "EventName"); + + migrationBuilder.CreateIndex( + name: "IX_Subscription_SubscriptionId", + table: "Subscription", + column: "SubscriptionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Workflow_InstanceId", + table: "Workflow", + column: "InstanceId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Workflow_NextExecution", + table: "Workflow", + column: "NextExecution"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Event"); + + migrationBuilder.DropTable( + name: "ExecutionError"); + + migrationBuilder.DropTable( + name: "ExtensionAttribute"); + + migrationBuilder.DropTable( + name: "ScheduledCommand"); + + migrationBuilder.DropTable( + name: "Subscription"); + + migrationBuilder.DropTable( + name: "ExecutionPointer"); + + migrationBuilder.DropTable( + name: "Workflow"); + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/Migrations/OracleContextModelSnapshot.cs b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/OracleContextModelSnapshot.cs new file mode 100644 index 000000000..7d88a0220 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/Migrations/OracleContextModelSnapshot.cs @@ -0,0 +1,381 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Oracle.EntityFrameworkCore.Metadata; +using WorkflowCore.Persistence.Oracle; + +#nullable disable + +namespace WorkflowCore.Persistence.Oracle.Migrations +{ + [DbContext(typeof(OracleContext))] + partial class OracleContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder +#if NET6_0 + .HasAnnotation("ProductVersion", "7.0.0") +#elif NET8_0 + .HasAnnotation("ProductVersion", "9.0.9") +#else + .HasAnnotation("ProductVersion", "9.0.9") +#endif + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("EventData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EventId") + .HasColumnType("RAW(16)"); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("IsProcessed") + .HasColumnType("NUMBER(1)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventId") + .IsUnique(); + + b.HasIndex("EventTime"); + + b.HasIndex("IsProcessed"); + + b.HasIndex("EventName", "EventKey"); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionError", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("ErrorTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("ExecutionPointerId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("Message") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("WorkflowId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.HasKey("PersistenceId"); + + b.ToTable("ExecutionError", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("Active") + .HasColumnType("NUMBER(1)"); + + b.Property("Children") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("ContextItem") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EndTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("EventData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("EventKey") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("EventName") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("EventPublished") + .HasColumnType("NUMBER(1)"); + + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("NVARCHAR2(50)"); + + b.Property("Outcome") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("PersistenceData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("PredecessorId") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("RetryCount") + .HasColumnType("NUMBER(10)"); + + b.Property("Scope") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("SleepUntil") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("StartTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("Status") + .HasColumnType("NUMBER(10)"); + + b.Property("StepId") + .HasColumnType("NUMBER(10)"); + + b.Property("StepName") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("WorkflowId") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("ExecutionPointer", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("AttributeKey") + .HasMaxLength(100) + .HasColumnType("NVARCHAR2(100)"); + + b.Property("AttributeValue") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("ExecutionPointerId") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecutionPointerId"); + + b.ToTable("ExtensionAttribute", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedScheduledCommand", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("CommandName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Data") + .HasMaxLength(500) + .HasColumnType("NVARCHAR2(500)"); + + b.Property("ExecuteTime") + .HasColumnType("NUMBER(19)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecuteTime"); + + b.HasIndex("CommandName", "Data") + .IsUnique() + .HasFilter("\"CommandName\" IS NOT NULL AND \"Data\" IS NOT NULL"); + + b.ToTable("ScheduledCommand", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedSubscription", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExecutionPointerId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExternalToken") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("ExternalTokenExpiry") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("ExternalWorkerId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("StepId") + .HasColumnType("NUMBER(10)"); + + b.Property("SubscribeAsOf") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("SubscriptionData") + .HasColumnType("NVARCHAR2(2000)"); + + b.Property("SubscriptionId") + .HasMaxLength(200) + .HasColumnType("RAW(16)"); + + b.Property("WorkflowId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventKey"); + + b.HasIndex("EventName"); + + b.HasIndex("SubscriptionId") + .IsUnique(); + + b.ToTable("Subscription", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("NUMBER(19)"); + + OraclePropertyBuilderExtensions.UseIdentityColumn(b.Property("PersistenceId"), 1L, 1); + + b.Property("CompleteTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("CreateTime") + .HasColumnType("TIMESTAMP(7)"); + + b.Property("Data") + .HasColumnType("CLOB"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("NVARCHAR2(500)"); + + b.Property("InstanceId") + .HasMaxLength(200) + .HasColumnType("RAW(16)"); + + b.Property("NextExecution") + .HasColumnType("NUMBER(19)"); + + b.Property("Reference") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.Property("Status") + .HasColumnType("NUMBER(10)"); + + b.Property("Version") + .HasColumnType("NUMBER(10)"); + + b.Property("WorkflowDefinitionId") + .HasMaxLength(200) + .HasColumnType("NVARCHAR2(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.HasIndex("NextExecution"); + + b.ToTable("Workflow", (string)null); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", "Workflow") + .WithMany("ExecutionPointers") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", "ExecutionPointer") + .WithMany("ExtensionAttributes") + .HasForeignKey("ExecutionPointerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExecutionPointer"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Navigation("ExtensionAttributes"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Navigation("ExecutionPointers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/OracleContext.cs b/src/providers/WorkflowCore.Persistence.Oracle/OracleContext.cs new file mode 100644 index 000000000..d6619c4b3 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/OracleContext.cs @@ -0,0 +1,72 @@ +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Oracle.EntityFrameworkCore.Infrastructure; + +using WorkflowCore.Persistence.EntityFramework.Models; +using WorkflowCore.Persistence.EntityFramework.Services; + +namespace WorkflowCore.Persistence.Oracle +{ + public class OracleContext : WorkflowDbContext + { + private readonly string _connectionString; + private readonly Action _oracleOptionsAction; + + public OracleContext(string connectionString, Action oracleOptionsAction = null) + { + _connectionString = connectionString; + _oracleOptionsAction = oracleOptionsAction; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder.UseOracle(_connectionString, _oracleOptionsAction); + } + + protected override void ConfigureSubscriptionStorage(EntityTypeBuilder builder) + { + builder.ToTable("Subscription"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureWorkflowStorage(EntityTypeBuilder builder) + { + builder.ToTable("Workflow"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureExecutionPointerStorage(EntityTypeBuilder builder) + { + builder.ToTable("ExecutionPointer"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureExecutionErrorStorage(EntityTypeBuilder builder) + { + builder.ToTable("ExecutionError"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureExetensionAttributeStorage(EntityTypeBuilder builder) + { + builder.ToTable("ExtensionAttribute"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureEventStorage(EntityTypeBuilder builder) + { + builder.ToTable("Event"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + + protected override void ConfigureScheduledCommandStorage(EntityTypeBuilder builder) + { + builder.ToTable("ScheduledCommand"); + builder.Property(x => x.PersistenceId).ValueGeneratedOnAdd(); + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/OracleContextFactory.cs b/src/providers/WorkflowCore.Persistence.Oracle/OracleContextFactory.cs new file mode 100644 index 000000000..e2d9c6e1a --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/OracleContextFactory.cs @@ -0,0 +1,26 @@ +using System; + +using Oracle.EntityFrameworkCore.Infrastructure; + +using WorkflowCore.Persistence.EntityFramework.Interfaces; +using WorkflowCore.Persistence.EntityFramework.Services; + +namespace WorkflowCore.Persistence.Oracle +{ + public class OracleContextFactory : IWorkflowDbContextFactory + { + private readonly string _connectionString; + private readonly Action _oracleOptionsAction; + + public OracleContextFactory(string connectionString, Action oracleOptionsAction = null) + { + _connectionString = connectionString; + _oracleOptionsAction = oracleOptionsAction; + } + + public WorkflowDbContext Build() + { + return new OracleContext(_connectionString, _oracleOptionsAction); + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/README.md b/src/providers/WorkflowCore.Persistence.Oracle/README.md new file mode 100644 index 000000000..1dd74ee7c --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/README.md @@ -0,0 +1,31 @@ +# Oracle Persistence provider for Workflow Core + +Provides support to persist workflows running on [Workflow Core](../../README.md) to an Oracle database. + +## Installing + +Install the NuGet package "WorkflowCore.Persistence.Oracle" + +``` +PM> Install-Package WorkflowCore.Persistence.Oracle -Pre +``` + +## Usage + +Use the .UseOracle extension method when building your service provider. + +```C# +services.AddWorkflow(x => x.UseOracle(@"Server=127.0.0.1;Database=workflow;User=root;Password=password;", true, true)); +``` + +You can also add specific database version compatibility if needed. + +```C# +services.AddWorkflow(x => + { + x.UseOracle(connectionString, false, true, options => + { + options.UseOracleSQLCompatibility(OracleSQLCompatibility.DatabaseVersion19); + }); + }); +``` \ No newline at end of file diff --git a/src/providers/WorkflowCore.Persistence.Oracle/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Persistence.Oracle/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..704916fbe --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +using Oracle.EntityFrameworkCore.Infrastructure; + +using WorkflowCore.Interface; +using WorkflowCore.Models; +using WorkflowCore.Persistence.EntityFramework.Services; + +namespace WorkflowCore.Persistence.Oracle +{ + public static class ServiceCollectionExtensions + { + public static WorkflowOptions UseOracle(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, Action oracleOptionsAction = null) + { + options.UsePersistence(sp => new EntityFrameworkPersistenceProvider(new OracleContextFactory(connectionString, oracleOptionsAction), canCreateDB, canMigrateDB)); + options.Services.AddTransient(sp => new WorkflowPurger(new OracleContextFactory(connectionString, oracleOptionsAction))); + return options; + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.Oracle/WorkflowCore.Persistence.Oracle.csproj b/src/providers/WorkflowCore.Persistence.Oracle/WorkflowCore.Persistence.Oracle.csproj new file mode 100644 index 000000000..138ec3b95 --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.Oracle/WorkflowCore.Persistence.Oracle.csproj @@ -0,0 +1,50 @@ + + + + Workflow Core Oracle Persistence Provider + 1.0.0 + Christian Jundt + net6.0;net8.0 + WorkflowCore.Persistence.Oracle + WorkflowCore.Persistence.Oracle + workflow;.NET;Core;state machine;WorkflowCore;Oracle + https://github.com/danielgerlag/workflow-core + https://github.com/danielgerlag/workflow-core/blob/master/LICENSE.md + git + https://github.com/danielgerlag/workflow-core.git + false + false + false + Provides support to persist workflows running on Workflow Core to a Oracle database. + + + + + All + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + All + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + 7.21.13 + + + + + + + + + diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.Designer.cs b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.Designer.cs new file mode 100644 index 000000000..63e6094bb --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.Designer.cs @@ -0,0 +1,377 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WorkflowCore.Persistence.PostgreSQL; + +#nullable disable + +namespace WorkflowCore.Persistence.PostgreSQL.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20250807084543_ChangeDateTimeTypeForPostgreSQL")] + partial class ChangeDateTimeTypeForPostgreSQL + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.19") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("EventData") + .HasColumnType("text"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EventTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventId") + .IsUnique(); + + b.HasIndex("EventTime"); + + b.HasIndex("IsProcessed"); + + b.HasIndex("EventName", "EventKey"); + + b.ToTable("Event", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionError", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("ErrorTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionPointerId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("WorkflowId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("PersistenceId"); + + b.ToTable("ExecutionError", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Children") + .HasColumnType("text"); + + b.Property("ContextItem") + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventData") + .HasColumnType("text"); + + b.Property("EventKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventPublished") + .HasColumnType("boolean"); + + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Outcome") + .HasColumnType("text"); + + b.Property("PersistenceData") + .HasColumnType("text"); + + b.Property("PredecessorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("text"); + + b.Property("SleepUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StepId") + .HasColumnType("integer"); + + b.Property("StepName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("bigint"); + + b.HasKey("PersistenceId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("ExecutionPointer", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("AttributeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AttributeValue") + .HasColumnType("text"); + + b.Property("ExecutionPointerId") + .HasColumnType("bigint"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecutionPointerId"); + + b.ToTable("ExtensionAttribute", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedScheduledCommand", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("CommandName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Data") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExecuteTime") + .HasColumnType("bigint"); + + b.HasKey("PersistenceId"); + + b.HasIndex("ExecuteTime"); + + b.HasIndex("CommandName", "Data") + .IsUnique(); + + b.ToTable("ScheduledCommand", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedSubscription", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("EventKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EventName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExecutionPointerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalTokenExpiry") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalWorkerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StepId") + .HasColumnType("integer"); + + b.Property("SubscribeAsOf") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionData") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasMaxLength(200) + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("EventKey"); + + b.HasIndex("EventName"); + + b.HasIndex("SubscriptionId") + .IsUnique(); + + b.ToTable("Subscription", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Property("PersistenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); + + b.Property("CompleteTime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstanceId") + .HasMaxLength(200) + .HasColumnType("uuid"); + + b.Property("NextExecution") + .HasColumnType("bigint"); + + b.Property("Reference") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .HasColumnType("integer"); + + b.Property("WorkflowDefinitionId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("PersistenceId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.HasIndex("NextExecution"); + + b.ToTable("Workflow", "wfc"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", "Workflow") + .WithMany("ExecutionPointers") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExtensionAttribute", b => + { + b.HasOne("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", "ExecutionPointer") + .WithMany("ExtensionAttributes") + .HasForeignKey("ExecutionPointerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExecutionPointer"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedExecutionPointer", b => + { + b.Navigation("ExtensionAttributes"); + }); + + modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedWorkflow", b => + { + b.Navigation("ExecutionPointers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.cs b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.cs new file mode 100644 index 000000000..42ebf73dc --- /dev/null +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/20250807084543_ChangeDateTimeTypeForPostgreSQL.cs @@ -0,0 +1,191 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorkflowCore.Persistence.PostgreSQL.Migrations +{ + /// + public partial class ChangeDateTimeTypeForPostgreSQL : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CreateTime", + schema: "wfc", + table: "Workflow", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "CompleteTime", + schema: "wfc", + table: "Workflow", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SubscribeAsOf", + schema: "wfc", + table: "Subscription", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "ExternalTokenExpiry", + schema: "wfc", + table: "Subscription", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "StartTime", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SleepUntil", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EndTime", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ErrorTime", + schema: "wfc", + table: "ExecutionError", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EventTime", + schema: "wfc", + table: "Event", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CreateTime", + schema: "wfc", + table: "Workflow", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "CompleteTime", + schema: "wfc", + table: "Workflow", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SubscribeAsOf", + schema: "wfc", + table: "Subscription", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "ExternalTokenExpiry", + schema: "wfc", + table: "Subscription", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "StartTime", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SleepUntil", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EndTime", + schema: "wfc", + table: "ExecutionPointer", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ErrorTime", + schema: "wfc", + table: "ExecutionError", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "EventTime", + schema: "wfc", + table: "Event", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + } + } +} diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/PostgresPersistenceProviderModelSnapshot.cs b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/PostgresPersistenceProviderModelSnapshot.cs index d0278e0c8..5feae3a10 100644 --- a/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/PostgresPersistenceProviderModelSnapshot.cs +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/Migrations/PostgresPersistenceProviderModelSnapshot.cs @@ -6,6 +6,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using WorkflowCore.Persistence.PostgreSQL; +#nullable disable + namespace WorkflowCore.Persistence.PostgreSQL.Migrations { [DbContext(typeof(PostgresContext))] @@ -15,16 +17,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.8") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); +#if NETSTANDARD2_1 + .HasAnnotation("ProductVersion", "5.0.1") +#elif NET6_0 + .HasAnnotation("ProductVersion", "7.0.0") +#elif NET8_0 + .HasAnnotation("ProductVersion", "9.0.9") +#else + .HasAnnotation("ProductVersion", "9.0.9") +#endif + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("EventData") .HasColumnType("text"); @@ -41,7 +53,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(200)"); b.Property("EventTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("IsProcessed") .HasColumnType("boolean"); @@ -64,11 +76,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("ErrorTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("ExecutionPointerId") .HasMaxLength(100) @@ -90,8 +103,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("Active") .HasColumnType("boolean"); @@ -103,7 +117,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("EndTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("EventData") .HasColumnType("text"); @@ -140,10 +154,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("SleepUntil") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("StartTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("Status") .HasColumnType("integer"); @@ -169,8 +183,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("AttributeKey") .HasMaxLength(100) @@ -193,8 +208,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("CommandName") .HasMaxLength(200) @@ -221,8 +237,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("EventKey") .HasMaxLength(200) @@ -241,7 +258,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(200)"); b.Property("ExternalTokenExpiry") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("ExternalWorkerId") .HasMaxLength(200) @@ -251,7 +268,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("SubscribeAsOf") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("SubscriptionData") .HasColumnType("text"); @@ -280,14 +297,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("PersistenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersistenceId")); b.Property("CompleteTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("CreateTime") - .HasColumnType("timestamp without time zone"); + .HasColumnType("timestamp with time zone"); b.Property("Data") .HasColumnType("text"); diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/PostgresContext.cs b/src/providers/WorkflowCore.Persistence.PostgreSQL/PostgresContext.cs index 3eb80b448..e9a371d7f 100644 --- a/src/providers/WorkflowCore.Persistence.PostgreSQL/PostgresContext.cs +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/PostgresContext.cs @@ -66,6 +66,54 @@ protected override void ConfigureScheduledCommandStorage(EntityTypeBuilder x.PersistenceId).ValueGeneratedOnAdd(); } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(x => + { + x.Property(p => p.CompleteTime) + .HasColumnType("timestamp with time zone"); + + x.Property(p => p.CreateTime) + .HasColumnType("timestamp with time zone"); + }); + + modelBuilder.Entity(x => + { + x.Property(p => p.SleepUntil) + .HasColumnType("timestamp with time zone"); + + x.Property(p => p.StartTime) + .HasColumnType("timestamp with time zone"); + + x.Property(p => p.EndTime) + .HasColumnType("timestamp with time zone"); + + }); + + modelBuilder.Entity(x => + { + x.Property(p => p.ErrorTime) + .HasColumnType("timestamp with time zone"); + + }); + + modelBuilder.Entity(x => + { + x.Property(p => p.SubscribeAsOf) + .HasColumnType("timestamp with time zone"); + + x.Property(p => p.ExternalTokenExpiry) + .HasColumnType("timestamp with time zone"); + }); + + modelBuilder.Entity( + x => x.Property(x => x.EventTime) + .HasColumnType("timestamp with time zone") + ); + } } } diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Persistence.PostgreSQL/ServiceCollectionExtensions.cs index 9366c3936..821e541f1 100644 --- a/src/providers/WorkflowCore.Persistence.PostgreSQL/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/ServiceCollectionExtensions.cs @@ -9,10 +9,22 @@ namespace Microsoft.Extensions.DependencyInjection { public static class ServiceCollectionExtensions { - public static WorkflowOptions UsePostgreSQL(this WorkflowOptions options, - string connectionString, bool canCreateDB, bool canMigrateDB, string schemaName="wfc") + private static readonly Func DefaultProviderFactory = + (sqlContextFactory, canCreateDb, canMigrateDb) => + new EntityFrameworkPersistenceProvider(sqlContextFactory, canCreateDb, canMigrateDb); + + private static readonly Func OptimizedProviderFactory = + (sqlContextFactory, canCreateDb, canMigrateDb) => + new LargeDataOptimizedEntityFrameworkPersistenceProvider(sqlContextFactory, canCreateDb, canMigrateDb); + + public static WorkflowOptions UsePostgreSQL(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, string schemaName = "wfc") => + options.UsePostgreSQL(connectionString, canCreateDB, canMigrateDB, false, schemaName); + + public static WorkflowOptions UsePostgreSQL(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, bool largeDataOptimized, string schemaName="wfc") { - options.UsePersistence(sp => new EntityFrameworkPersistenceProvider(new PostgresContextFactory(connectionString, schemaName), canCreateDB, canMigrateDB)); + var providerFactory = largeDataOptimized ? OptimizedProviderFactory : DefaultProviderFactory; + + options.UsePersistence(_ => providerFactory(new PostgresContextFactory(connectionString, schemaName), canCreateDB, canMigrateDB)); options.Services.AddTransient(sp => new WorkflowPurger(new PostgresContextFactory(connectionString, schemaName))); return options; } diff --git a/src/providers/WorkflowCore.Persistence.PostgreSQL/WorkflowCore.Persistence.PostgreSQL.csproj b/src/providers/WorkflowCore.Persistence.PostgreSQL/WorkflowCore.Persistence.PostgreSQL.csproj index e321a340a..b79511625 100644 --- a/src/providers/WorkflowCore.Persistence.PostgreSQL/WorkflowCore.Persistence.PostgreSQL.csproj +++ b/src/providers/WorkflowCore.Persistence.PostgreSQL/WorkflowCore.Persistence.PostgreSQL.csproj @@ -3,7 +3,7 @@ Workflow Core PostgreSQL Persistence Provider Daniel Gerlag - netstandard2.1;net6.0 + netstandard2.1;net6.0;net8.0 WorkflowCore.Persistence.PostgreSQL WorkflowCore.Persistence.PostgreSQL workflow;.NET;Core;state machine;WorkflowCore;PostgreSQL @@ -23,19 +23,31 @@ - - - + + + All - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + All + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + All diff --git a/src/providers/WorkflowCore.Persistence.SqlServer/Migrations/SqlServerPersistenceProviderModelSnapshot.cs b/src/providers/WorkflowCore.Persistence.SqlServer/Migrations/SqlServerPersistenceProviderModelSnapshot.cs index 39da38276..299cdb315 100644 --- a/src/providers/WorkflowCore.Persistence.SqlServer/Migrations/SqlServerPersistenceProviderModelSnapshot.cs +++ b/src/providers/WorkflowCore.Persistence.SqlServer/Migrations/SqlServerPersistenceProviderModelSnapshot.cs @@ -16,7 +16,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("ProductVersion", "5.0.8") +#if NETSTANDARD2_1 + .HasAnnotation("ProductVersion", "5.0.1") +#elif NET6_0 + .HasAnnotation("ProductVersion", "7.0.0") +#elif NET8_0 || NET9_0 + .HasAnnotation("ProductVersion", "9.0.9") +#else + .HasAnnotation("ProductVersion", "9.0.9") +#endif .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); modelBuilder.Entity("WorkflowCore.Persistence.EntityFramework.Models.PersistedEvent", b => diff --git a/src/providers/WorkflowCore.Persistence.SqlServer/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Persistence.SqlServer/ServiceCollectionExtensions.cs index 0a54b1ea2..e9477396f 100644 --- a/src/providers/WorkflowCore.Persistence.SqlServer/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.Persistence.SqlServer/ServiceCollectionExtensions.cs @@ -9,9 +9,22 @@ namespace Microsoft.Extensions.DependencyInjection { public static class ServiceCollectionExtensions { - public static WorkflowOptions UseSqlServer(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, Action initAction = null) + private static readonly Func DefaultProviderFactory = + (sqlContextFactory, canCreateDb, canMigrateDb) => + new EntityFrameworkPersistenceProvider(sqlContextFactory, canCreateDb, canMigrateDb); + + private static readonly Func OptimizedProviderFactory = + (sqlContextFactory, canCreateDb, canMigrateDb) => + new LargeDataOptimizedEntityFrameworkPersistenceProvider(sqlContextFactory, canCreateDb, canMigrateDb); + + public static WorkflowOptions UseSqlServer(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, Action initAction = null) => + options.UseSqlServer(connectionString, canCreateDB, canMigrateDB, false, initAction); + + public static WorkflowOptions UseSqlServer(this WorkflowOptions options, string connectionString, bool canCreateDB, bool canMigrateDB, bool largeDataOptimized, Action initAction = null) { - options.UsePersistence(sp => new EntityFrameworkPersistenceProvider(new SqlContextFactory(connectionString, initAction), canCreateDB, canMigrateDB)); + var providerFactory = largeDataOptimized ? OptimizedProviderFactory : DefaultProviderFactory; + + options.UsePersistence(_ => providerFactory(new SqlContextFactory(connectionString, initAction), canCreateDB, canMigrateDB)); options.Services.AddTransient(sp => new WorkflowPurger(new SqlContextFactory(connectionString, initAction))); return options; } diff --git a/src/providers/WorkflowCore.Persistence.SqlServer/WorkflowCore.Persistence.SqlServer.csproj b/src/providers/WorkflowCore.Persistence.SqlServer/WorkflowCore.Persistence.SqlServer.csproj index be099421a..f617bfa05 100644 --- a/src/providers/WorkflowCore.Persistence.SqlServer/WorkflowCore.Persistence.SqlServer.csproj +++ b/src/providers/WorkflowCore.Persistence.SqlServer/WorkflowCore.Persistence.SqlServer.csproj @@ -4,7 +4,7 @@ Workflow Core SQL Server Persistence Provider 1.8.0 Daniel Gerlag - netstandard2.1;net6.0 + netstandard2.1;net6.0;net8.0;net9.0 WorkflowCore.Persistence.SqlServer WorkflowCore.Persistence.SqlServer workflow;.NET;Core;state machine;WorkflowCore @@ -24,11 +24,33 @@ - - + + All - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + All + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + All + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/providers/WorkflowCore.Persistence.Sqlite/WorkflowCore.Persistence.Sqlite.csproj b/src/providers/WorkflowCore.Persistence.Sqlite/WorkflowCore.Persistence.Sqlite.csproj index 0bd622bde..d1fc08d7c 100644 --- a/src/providers/WorkflowCore.Persistence.Sqlite/WorkflowCore.Persistence.Sqlite.csproj +++ b/src/providers/WorkflowCore.Persistence.Sqlite/WorkflowCore.Persistence.Sqlite.csproj @@ -4,7 +4,7 @@ Workflow Core Sqlite Persistence Provider 1.5.0 Daniel Gerlag - netstandard2.1;net6.0 + netstandard2.1;net6.0;net8.0 WorkflowCore.Persistence.Sqlite WorkflowCore.Persistence.Sqlite workflow;.NET;Core;state machine;WorkflowCore;Sqlite @@ -24,7 +24,11 @@ - + + + + + diff --git a/src/providers/WorkflowCore.Providers.AWS/README.md b/src/providers/WorkflowCore.Providers.AWS/README.md index f9c000662..0a9286f37 100644 --- a/src/providers/WorkflowCore.Providers.AWS/README.md +++ b/src/providers/WorkflowCore.Providers.AWS/README.md @@ -34,6 +34,18 @@ services.AddWorkflow(cfg => If any AWS resources do not exists, they will be automatcially created. By default, all DynamoDB tables and indexes will be provisioned with a throughput of 1, you can modify these values from the AWS console. You may also specify a prefix for the dynamo table names. +If you have a preconfigured dynamoClient, you can pass this in instead of the credentials and config +```C# +var client = new AmazonDynamoDBClient(); +var sqsClient = new AmazonSQSClient(); +services.AddWorkflow(cfg => +{ + cfg.UseAwsDynamoPersistenceWithProvisionedClient(client, "table-prefix"); + cfg.UseAwsDynamoLockingWithProvisionedClient(client, "workflow-core-locks"); + cfg.UseAwsSimpleQueueServiceWithProvisionedClient(sqsClient, "queues-prefix"); +}); +``` + ## Usage (Kinesis) diff --git a/src/providers/WorkflowCore.Providers.AWS/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Providers.AWS/ServiceCollectionExtensions.cs index 57b6f6bc8..c3c545f80 100644 --- a/src/providers/WorkflowCore.Providers.AWS/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.Providers.AWS/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using System; using Amazon; using Amazon.DynamoDBv2; +using Amazon.Kinesis; using Amazon.Runtime; using Amazon.SQS; using Microsoft.Extensions.Logging; @@ -15,28 +16,55 @@ public static class ServiceCollectionExtensions { public static WorkflowOptions UseAwsSimpleQueueService(this WorkflowOptions options, AWSCredentials credentials, AmazonSQSConfig config, string queuesPrefix = "workflowcore") { - options.UseQueueProvider(sp => new SQSQueueProvider(credentials, config, sp.GetService(), queuesPrefix)); + var sqsClient = new AmazonSQSClient(credentials, config); + return options.UseAwsSimpleQueueServiceWithProvisionedClient(sqsClient, queuesPrefix); + } + + public static WorkflowOptions UseAwsSimpleQueueServiceWithProvisionedClient(this WorkflowOptions options, AmazonSQSClient sqsClient, string queuesPrefix = "workflowcore") + { + options.UseQueueProvider(sp => new SQSQueueProvider(sqsClient, sp.GetService(), queuesPrefix)); return options; } public static WorkflowOptions UseAwsDynamoLocking(this WorkflowOptions options, AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName) { - options.UseDistributedLockManager(sp => new DynamoLockProvider(credentials, config, tableName, sp.GetService(), sp.GetService())); + var dbClient = new AmazonDynamoDBClient(credentials, config); + return options.UseAwsDynamoLockingWithProvisionedClient(dbClient, tableName); + } + + public static WorkflowOptions UseAwsDynamoLockingWithProvisionedClient (this WorkflowOptions options, AmazonDynamoDBClient dynamoClient, string tableName) + { + options.UseDistributedLockManager(sp => new DynamoLockProvider(dynamoClient, tableName, sp.GetService(), sp.GetService())); return options; } public static WorkflowOptions UseAwsDynamoPersistence(this WorkflowOptions options, AWSCredentials credentials, AmazonDynamoDBConfig config, string tablePrefix) { - options.Services.AddTransient(sp => new DynamoDbProvisioner(credentials, config, tablePrefix, sp.GetService())); - options.UsePersistence(sp => new DynamoPersistenceProvider(credentials, config, sp.GetService(), tablePrefix, sp.GetService())); + var dbClient = new AmazonDynamoDBClient(credentials, config); + return options.UseAwsDynamoPersistenceWithProvisionedClient(dbClient, tablePrefix); + } + + public static WorkflowOptions UseAwsDynamoPersistenceWithProvisionedClient(this WorkflowOptions options, AmazonDynamoDBClient dynamoClient, string tablePrefix) + { + options.Services.AddTransient(sp => new DynamoDbProvisioner(dynamoClient, tablePrefix, sp.GetService())); + options.UsePersistence(sp => new DynamoPersistenceProvider(dynamoClient, sp.GetService(), tablePrefix, sp.GetService())); return options; } public static WorkflowOptions UseAwsKinesis(this WorkflowOptions options, AWSCredentials credentials, RegionEndpoint region, string appName, string streamName) { - options.Services.AddTransient(sp => new KinesisTracker(credentials, region, "workflowcore_kinesis", sp.GetService())); - options.Services.AddTransient(sp => new KinesisStreamConsumer(credentials, region, sp.GetService(), sp.GetService(), sp.GetService(), sp.GetService())); - options.UseEventHub(sp => new KinesisProvider(credentials, region, appName, streamName, sp.GetService(), sp.GetService())); + var kinesisClient = new AmazonKinesisClient(credentials, region); + var dynamoClient = new AmazonDynamoDBClient(credentials, region); + + return options.UseAwsKinesisWithProvisionedClients(kinesisClient, dynamoClient,appName, streamName); + + } + + public static WorkflowOptions UseAwsKinesisWithProvisionedClients(this WorkflowOptions options, AmazonKinesisClient kinesisClient, AmazonDynamoDBClient dynamoDbClient, string appName, string streamName) + { + options.Services.AddTransient(sp => new KinesisTracker(dynamoDbClient, "workflowcore_kinesis", sp.GetService())); + options.Services.AddTransient(sp => new KinesisStreamConsumer(kinesisClient, sp.GetService(), sp.GetService(), sp.GetService(), sp.GetService())); + options.UseEventHub(sp => new KinesisProvider(kinesisClient, appName, streamName, sp.GetService(), sp.GetService())); return options; } } diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoDbProvisioner.cs b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoDbProvisioner.cs index 3f381c8c3..887d11a7a 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoDbProvisioner.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoDbProvisioner.cs @@ -15,10 +15,10 @@ public class DynamoDbProvisioner : IDynamoDbProvisioner private readonly IAmazonDynamoDB _client; private readonly string _tablePrefix; - public DynamoDbProvisioner(AWSCredentials credentials, AmazonDynamoDBConfig config, string tablePrefix, ILoggerFactory logFactory) + public DynamoDbProvisioner(AmazonDynamoDBClient dynamoDBClient, string tablePrefix, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(); - _client = new AmazonDynamoDBClient(credentials, config); + _client = dynamoDBClient; _tablePrefix = tablePrefix; } diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoLockProvider.cs b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoLockProvider.cs index 6f4aca0e8..0863f1393 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoLockProvider.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoLockProvider.cs @@ -25,10 +25,10 @@ public class DynamoLockProvider : IDistributedLockProvider private readonly AutoResetEvent _mutex = new AutoResetEvent(true); private readonly IDateTimeProvider _dateTimeProvider; - public DynamoLockProvider(AWSCredentials credentials, AmazonDynamoDBConfig config, string tableName, ILoggerFactory logFactory, IDateTimeProvider dateTimeProvider) + public DynamoLockProvider(AmazonDynamoDBClient dynamoDBClient, string tableName, ILoggerFactory logFactory, IDateTimeProvider dateTimeProvider) { _logger = logFactory.CreateLogger(); - _client = new AmazonDynamoDBClient(credentials, config); + _client = dynamoDBClient; _localLocks = new List(); _tableName = tableName; _nodeId = Guid.NewGuid().ToString(); diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoPersistenceProvider.cs b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoPersistenceProvider.cs index 09f1dbc4c..b22eb955e 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/DynamoPersistenceProvider.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/DynamoPersistenceProvider.cs @@ -26,10 +26,10 @@ public class DynamoPersistenceProvider : IPersistenceProvider public bool SupportsScheduledCommands => false; - public DynamoPersistenceProvider(AWSCredentials credentials, AmazonDynamoDBConfig config, IDynamoDbProvisioner provisioner, string tablePrefix, ILoggerFactory logFactory) + public DynamoPersistenceProvider(AmazonDynamoDBClient dynamoDBClient, IDynamoDbProvisioner provisioner, string tablePrefix, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(); - _client = new AmazonDynamoDBClient(credentials, config); + _client = dynamoDBClient; _tablePrefix = tablePrefix; _provisioner = provisioner; } @@ -389,7 +389,7 @@ public async Task MarkEventUnprocessed(string id, CancellationToken cancellation { { "id", new AttributeValue(id) } }, - UpdateExpression = "ADD not_processed = :n", + UpdateExpression = "ADD not_processed :n", ExpressionAttributeValues = new Dictionary { { ":n" , new AttributeValue { N = 1.ToString() } } diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisProvider.cs b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisProvider.cs index 99d43d94f..d8aa519bd 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisProvider.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisProvider.cs @@ -26,7 +26,7 @@ public class KinesisProvider : ILifeCycleEventHub private readonly int _defaultShardCount = 1; private bool _started = false; - public KinesisProvider(AWSCredentials credentials, RegionEndpoint region, string appName, string streamName, IKinesisStreamConsumer consumer, ILoggerFactory logFactory) + public KinesisProvider(AmazonKinesisClient kinesisClient, string appName, string streamName, IKinesisStreamConsumer consumer, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(GetType()); _appName = appName; @@ -34,7 +34,7 @@ public KinesisProvider(AWSCredentials credentials, RegionEndpoint region, string _consumer = consumer; _serializer = new JsonSerializer(); _serializer.TypeNameHandling = TypeNameHandling.All; - _client = new AmazonKinesisClient(credentials, region); + _client = kinesisClient; } public async Task PublishNotification(LifeCycleEvent evt) diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisStreamConsumer.cs b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisStreamConsumer.cs index 799125a0d..5c89f7837 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisStreamConsumer.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisStreamConsumer.cs @@ -25,12 +25,12 @@ public class KinesisStreamConsumer : IKinesisStreamConsumer, IDisposable private ICollection _subscribers = new HashSet(); private readonly IDateTimeProvider _dateTimeProvider; - public KinesisStreamConsumer(AWSCredentials credentials, RegionEndpoint region, IKinesisTracker tracker, IDistributedLockProvider lockManager, ILoggerFactory logFactory, IDateTimeProvider dateTimeProvider) + public KinesisStreamConsumer(AmazonKinesisClient kinesisClient, IKinesisTracker tracker, IDistributedLockProvider lockManager, ILoggerFactory logFactory, IDateTimeProvider dateTimeProvider) { _logger = logFactory.CreateLogger(GetType()); _tracker = tracker; _lockManager = lockManager; - _client = new AmazonKinesisClient(credentials, region); + _client = kinesisClient; _processTask = new Task(Process); _processTask.Start(); _dateTimeProvider = dateTimeProvider; diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisTracker.cs b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisTracker.cs index 9c5548420..d7c028c46 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/KinesisTracker.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/KinesisTracker.cs @@ -17,10 +17,10 @@ public class KinesisTracker : IKinesisTracker private readonly string _tableName; private bool _tableConfirmed = false; - public KinesisTracker(AWSCredentials credentials, RegionEndpoint region, string tableName, ILoggerFactory logFactory) + public KinesisTracker(AmazonDynamoDBClient client, string tableName, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(GetType()); - _client = new AmazonDynamoDBClient(credentials, region); + _client = client; _tableName = tableName; } diff --git a/src/providers/WorkflowCore.Providers.AWS/Services/SQSQueueProvider.cs b/src/providers/WorkflowCore.Providers.AWS/Services/SQSQueueProvider.cs index dd1c15e14..c15fb02af 100644 --- a/src/providers/WorkflowCore.Providers.AWS/Services/SQSQueueProvider.cs +++ b/src/providers/WorkflowCore.Providers.AWS/Services/SQSQueueProvider.cs @@ -21,10 +21,10 @@ public class SQSQueueProvider : IQueueProvider public bool IsDequeueBlocking => true; - public SQSQueueProvider(AWSCredentials credentials, AmazonSQSConfig config, ILoggerFactory logFactory, string queuesPrefix) + public SQSQueueProvider(AmazonSQSClient sqsClient, ILoggerFactory logFactory, string queuesPrefix) { _logger = logFactory.CreateLogger(); - _client = new AmazonSQSClient(credentials, config); + _client = sqsClient; _queuesPrefix = queuesPrefix; } diff --git a/src/providers/WorkflowCore.Providers.Azure/Models/ControlledLock.cs b/src/providers/WorkflowCore.Providers.Azure/Models/ControlledLock.cs index 5b0343456..587378e15 100644 --- a/src/providers/WorkflowCore.Providers.Azure/Models/ControlledLock.cs +++ b/src/providers/WorkflowCore.Providers.Azure/Models/ControlledLock.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.WindowsAzure.Storage.Blob; +using Azure.Storage.Blobs.Specialized; namespace WorkflowCore.Providers.Azure.Models { @@ -7,9 +6,9 @@ class ControlledLock { public string Id { get; set; } public string LeaseId { get; set; } - public CloudBlockBlob Blob { get; set; } + public BlockBlobClient Blob { get; set; } - public ControlledLock(string id, string leaseId, CloudBlockBlob blob) + public ControlledLock(string id, string leaseId, BlockBlobClient blob) { Id = id; LeaseId = leaseId; diff --git a/src/providers/WorkflowCore.Providers.Azure/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.Providers.Azure/ServiceCollectionExtensions.cs index 0aa1963e4..e08d0445f 100644 --- a/src/providers/WorkflowCore.Providers.Azure/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.Providers.Azure/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using Microsoft.Extensions.Logging; +using System; +using Azure.Core; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Logging; using WorkflowCore.Interface; using WorkflowCore.Models; using WorkflowCore.Providers.Azure.Interface; @@ -15,6 +18,13 @@ public static WorkflowOptions UseAzureSynchronization(this WorkflowOptions optio return options; } + public static WorkflowOptions UseAzureSynchronization(this WorkflowOptions options, Uri blobEndpoint, Uri queueEndpoint, TokenCredential tokenCredential) + { + options.UseQueueProvider(sp => new AzureStorageQueueProvider(queueEndpoint, tokenCredential, sp.GetService())); + options.UseDistributedLockManager(sp => new AzureLockManager(blobEndpoint, tokenCredential, sp.GetService())); + return options; + } + public static WorkflowOptions UseAzureServiceBusEventHub( this WorkflowOptions options, string connectionString, @@ -27,10 +37,62 @@ public static WorkflowOptions UseAzureServiceBusEventHub( return options; } + public static WorkflowOptions UseAzureServiceBusEventHub( + this WorkflowOptions options, + string fullyQualifiedNamespace, + TokenCredential tokenCredential, + string topicName, + string subscriptionName) + { + options.UseEventHub(sp => new ServiceBusLifeCycleEventHub( + fullyQualifiedNamespace, tokenCredential, topicName, subscriptionName, sp.GetService())); + + return options; + } + public static WorkflowOptions UseCosmosDbPersistence( this WorkflowOptions options, string connectionString, string databaseId, + CosmosDbStorageOptions cosmosDbStorageOptions = null, + CosmosClientOptions clientOptions = null) + { + if (cosmosDbStorageOptions == null) + { + cosmosDbStorageOptions = new CosmosDbStorageOptions(); + } + + options.Services.AddSingleton(sp => new CosmosClientFactory(connectionString, clientOptions)); + options.Services.AddTransient(sp => new CosmosDbProvisioner(sp.GetService(), cosmosDbStorageOptions)); + options.Services.AddSingleton(sp => new WorkflowPurger(sp.GetService(), databaseId, cosmosDbStorageOptions)); + options.UsePersistence(sp => new CosmosDbPersistenceProvider(sp.GetService(), databaseId, sp.GetService(), cosmosDbStorageOptions)); + return options; + } + + public static WorkflowOptions UseCosmosDbPersistence( + this WorkflowOptions options, + CosmosClient client, + string databaseId, + CosmosDbStorageOptions cosmosDbStorageOptions = null, + CosmosClientOptions clientOptions = null) + { + if (cosmosDbStorageOptions == null) + { + cosmosDbStorageOptions = new CosmosDbStorageOptions(); + } + + options.Services.AddSingleton(sp => new CosmosClientFactory(client)); + options.Services.AddTransient(sp => new CosmosDbProvisioner(sp.GetService(), cosmosDbStorageOptions)); + options.Services.AddSingleton(sp => new WorkflowPurger(sp.GetService(), databaseId, cosmosDbStorageOptions)); + options.UsePersistence(sp => new CosmosDbPersistenceProvider(sp.GetService(), databaseId, sp.GetService(), cosmosDbStorageOptions)); + return options; + } + + public static WorkflowOptions UseCosmosDbPersistence( + this WorkflowOptions options, + string accountEndpoint, + TokenCredential tokenCredential, + string databaseId, CosmosDbStorageOptions cosmosDbStorageOptions = null) { if (cosmosDbStorageOptions == null) @@ -38,7 +100,7 @@ public static WorkflowOptions UseCosmosDbPersistence( cosmosDbStorageOptions = new CosmosDbStorageOptions(); } - options.Services.AddSingleton(sp => new CosmosClientFactory(connectionString)); + options.Services.AddSingleton(sp => new CosmosClientFactory(accountEndpoint, tokenCredential)); options.Services.AddTransient(sp => new CosmosDbProvisioner(sp.GetService(), cosmosDbStorageOptions)); options.Services.AddSingleton(sp => new WorkflowPurger(sp.GetService(), databaseId, cosmosDbStorageOptions)); options.UsePersistence(sp => new CosmosDbPersistenceProvider(sp.GetService(), databaseId, sp.GetService(), cosmosDbStorageOptions)); diff --git a/src/providers/WorkflowCore.Providers.Azure/Services/AzureLockManager.cs b/src/providers/WorkflowCore.Providers.Azure/Services/AzureLockManager.cs index c52e823c7..6780a77ad 100644 --- a/src/providers/WorkflowCore.Providers.Azure/Services/AzureLockManager.cs +++ b/src/providers/WorkflowCore.Providers.Azure/Services/AzureLockManager.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Azure.Core; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; using Microsoft.Extensions.Logging; -using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Blob; using WorkflowCore.Interface; using WorkflowCore.Providers.Azure.Models; @@ -13,11 +15,11 @@ namespace WorkflowCore.Providers.Azure.Services { public class AzureLockManager: IDistributedLockProvider { - private readonly CloudBlobClient _client; + private readonly BlobServiceClient _client; private readonly ILogger _logger; private readonly List _locks = new List(); private readonly AutoResetEvent _mutex = new AutoResetEvent(true); - private CloudBlobContainer _container; + private BlobContainerClient _container; private Timer _renewTimer; private TimeSpan LockTimeout => TimeSpan.FromMinutes(1); private TimeSpan RenewInterval => TimeSpan.FromSeconds(45); @@ -25,26 +27,31 @@ public class AzureLockManager: IDistributedLockProvider public AzureLockManager(string connectionString, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(); - var account = CloudStorageAccount.Parse(connectionString); - _client = account.CreateCloudBlobClient(); + _client = new BlobServiceClient(connectionString); + } + + public AzureLockManager(Uri blobEndpoint, TokenCredential tokenCredential, ILoggerFactory logFactory) + { + _logger = logFactory.CreateLogger(); + _client = new BlobServiceClient(blobEndpoint, tokenCredential); } public async Task AcquireLock(string Id, CancellationToken cancellationToken) { - var blob = _container.GetBlockBlobReference(Id); + var blob = _container.GetBlockBlobClient(Id); if (!await blob.ExistsAsync()) - await blob.UploadTextAsync(string.Empty); + await blob.UploadAsync(new MemoryStream()); if (_mutex.WaitOne()) { try { - var leaseId = await blob.AcquireLeaseAsync(LockTimeout); - _locks.Add(new ControlledLock(Id, leaseId, blob)); + var lease = await blob.GetBlobLeaseClient().AcquireAsync(LockTimeout); + _locks.Add(new ControlledLock(Id, lease.Value.LeaseId, blob)); return true; } - catch (StorageException ex) + catch (Exception ex) { _logger.LogDebug($"Failed to acquire lock {Id} - {ex.Message}"); return false; @@ -69,7 +76,7 @@ public async Task ReleaseLock(string Id) { try { - await entry.Blob.ReleaseLeaseAsync(AccessCondition.GenerateLeaseCondition(entry.LeaseId)); + await entry.Blob.GetBlobLeaseClient(entry.LeaseId).ReleaseAsync(); } catch (Exception ex) { @@ -87,7 +94,7 @@ public async Task ReleaseLock(string Id) public async Task Start() { - _container = _client.GetContainerReference("workflowcore-locks"); + _container = _client.GetBlobContainerClient("workflowcore-locks"); await _container.CreateIfNotExistsAsync(); _renewTimer = new Timer(RenewLeases, null, RenewInterval, RenewInterval); } @@ -128,7 +135,7 @@ private async Task RenewLock(ControlledLock entry) { try { - await entry.Blob.RenewLeaseAsync(AccessCondition.GenerateLeaseCondition(entry.LeaseId)); + await entry.Blob.GetBlobLeaseClient(entry.LeaseId).RenewAsync(); } catch (Exception ex) { diff --git a/src/providers/WorkflowCore.Providers.Azure/Services/AzureStorageQueueProvider.cs b/src/providers/WorkflowCore.Providers.Azure/Services/AzureStorageQueueProvider.cs index cbd76d1f7..3aea1d397 100644 --- a/src/providers/WorkflowCore.Providers.Azure/Services/AzureStorageQueueProvider.cs +++ b/src/providers/WorkflowCore.Providers.Azure/Services/AzureStorageQueueProvider.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Azure.Core; +using Azure.Storage.Queues; using Microsoft.Extensions.Logging; -using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Queue; using WorkflowCore.Interface; namespace WorkflowCore.Providers.Azure.Services @@ -13,41 +13,44 @@ public class AzureStorageQueueProvider : IQueueProvider { private readonly ILogger _logger; - private readonly Dictionary _queues = new Dictionary(); + private readonly Dictionary _queues = new Dictionary(); public bool IsDequeueBlocking => false; public AzureStorageQueueProvider(string connectionString, ILoggerFactory logFactory) { _logger = logFactory.CreateLogger(); - var account = CloudStorageAccount.Parse(connectionString); - var client = account.CreateCloudQueueClient(); + var client = new QueueServiceClient(connectionString); - _queues[QueueType.Workflow] = client.GetQueueReference("workflowcore-workflows"); - _queues[QueueType.Event] = client.GetQueueReference("workflowcore-events"); - _queues[QueueType.Index] = client.GetQueueReference("workflowcore-index"); + _queues[QueueType.Workflow] = client.GetQueueClient("workflowcore-workflows"); + _queues[QueueType.Event] = client.GetQueueClient("workflowcore-events"); + _queues[QueueType.Index] = client.GetQueueClient("workflowcore-index"); + } + + public AzureStorageQueueProvider(Uri queueEndpoint, TokenCredential tokenCredential, ILoggerFactory logFactory) + { + _logger = logFactory.CreateLogger(); + var client = new QueueServiceClient(queueEndpoint, tokenCredential); + + _queues[QueueType.Workflow] = client.GetQueueClient("workflowcore-workflows"); + _queues[QueueType.Event] = client.GetQueueClient("workflowcore-events"); + _queues[QueueType.Index] = client.GetQueueClient("workflowcore-index"); } public async Task QueueWork(string id, QueueType queue) { - var msg = new CloudQueueMessage(id); - await _queues[queue].AddMessageAsync(msg); + await _queues[queue].SendMessageAsync(id); } public async Task DequeueWork(QueueType queue, CancellationToken cancellationToken) { - CloudQueue cloudQueue = _queues[queue]; - - if (cloudQueue == null) - return null; - - var msg = await cloudQueue.GetMessageAsync(); + var msg = await _queues[queue].ReceiveMessageAsync(); - if (msg == null) + if (msg == null || msg.Value == null) return null; - await cloudQueue.DeleteMessageAsync(msg); - return msg.AsString; + await _queues[queue].DeleteMessageAsync(msg.Value.MessageId, msg.Value.PopReceipt); + return msg.Value.Body.ToString(); } public async Task Start() diff --git a/src/providers/WorkflowCore.Providers.Azure/Services/CosmosClientFactory.cs b/src/providers/WorkflowCore.Providers.Azure/Services/CosmosClientFactory.cs index 9cb4cc572..83f70f4d5 100644 --- a/src/providers/WorkflowCore.Providers.Azure/Services/CosmosClientFactory.cs +++ b/src/providers/WorkflowCore.Providers.Azure/Services/CosmosClientFactory.cs @@ -1,4 +1,5 @@ using System; +using Azure.Core; using Microsoft.Azure.Cosmos; using WorkflowCore.Providers.Azure.Interface; @@ -10,9 +11,19 @@ public class CosmosClientFactory : ICosmosClientFactory, IDisposable private CosmosClient _client; - public CosmosClientFactory(string connectionString) + public CosmosClientFactory(string connectionString, CosmosClientOptions clientOptions = null) { - _client = new CosmosClient(connectionString); + _client = new CosmosClient(connectionString, clientOptions); + } + + public CosmosClientFactory(CosmosClient client) + { + _client = client; + } + + public CosmosClientFactory(string accountEndpoint, TokenCredential tokenCredential) + { + _client = new CosmosClient(accountEndpoint, tokenCredential); } public CosmosClient GetCosmosClient() diff --git a/src/providers/WorkflowCore.Providers.Azure/Services/ServiceBusLifeCycleEventHub.cs b/src/providers/WorkflowCore.Providers.Azure/Services/ServiceBusLifeCycleEventHub.cs index 85f7a4245..f1ce10984 100644 --- a/src/providers/WorkflowCore.Providers.Azure/Services/ServiceBusLifeCycleEventHub.cs +++ b/src/providers/WorkflowCore.Providers.Azure/Services/ServiceBusLifeCycleEventHub.cs @@ -3,7 +3,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Azure.ServiceBus; +using Azure.Core; +using Azure.Messaging.ServiceBus; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using WorkflowCore.Interface; @@ -13,9 +14,11 @@ namespace WorkflowCore.Providers.Azure.Services { public class ServiceBusLifeCycleEventHub : ILifeCycleEventHub { - private readonly ITopicClient _topicClient; private readonly ILogger _logger; - private readonly ISubscriptionClient _subscriptionClient; + private readonly ServiceBusSender _sender; + private readonly ServiceBusReceiver _receiver; + private readonly ServiceBusProcessor _processor; + private readonly ICollection> _subscribers = new HashSet>(); private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings { @@ -29,20 +32,38 @@ public ServiceBusLifeCycleEventHub( string subscriptionName, ILoggerFactory logFactory) { - _subscriptionClient = new SubscriptionClient(connectionString, topicName, subscriptionName); - _topicClient = new TopicClient(connectionString, topicName); + var client = new ServiceBusClient(connectionString); + _sender = client.CreateSender(topicName); + _receiver = client.CreateReceiver(topicName, subscriptionName); + _processor = client.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions + { + AutoCompleteMessages = false + }); _logger = logFactory.CreateLogger(GetType()); } - public async Task PublishNotification(LifeCycleEvent evt) + public ServiceBusLifeCycleEventHub( + string fullyQualifiedNamespace, + TokenCredential tokenCredential, + string topicName, + string subscriptionName, + ILoggerFactory logFactory) { - var payload = JsonConvert.SerializeObject(evt, _serializerSettings); - var message = new Message(Encoding.Default.GetBytes(payload)) + var client = new ServiceBusClient(fullyQualifiedNamespace, tokenCredential); + _sender = client.CreateSender(topicName); + _receiver = client.CreateReceiver(topicName, subscriptionName); + _processor = client.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions { - Label = evt.Reference - }; + AutoCompleteMessages = false + }); + _logger = logFactory.CreateLogger(GetType()); + } - await _topicClient.SendAsync(message); + public async Task PublishNotification(LifeCycleEvent evt) + { + var payload = JsonConvert.SerializeObject(evt, _serializerSettings); + var message = new ServiceBusMessage(payload); + await _sender.SendMessageAsync(message); } public void Subscribe(Action action) @@ -50,45 +71,39 @@ public void Subscribe(Action action) _subscribers.Add(action); } - public Task Start() + public async Task Start() { - var messageHandlerOptions = new MessageHandlerOptions(ExceptionHandler) - { - AutoComplete = false - }; - - _subscriptionClient.RegisterMessageHandler(MessageHandler, messageHandlerOptions); - - return Task.CompletedTask; + _processor.ProcessErrorAsync += ExceptionHandler; + _processor.ProcessMessageAsync += MessageHandler; + await _processor.StartProcessingAsync(); } public async Task Stop() { - await _topicClient.CloseAsync(); - await _subscriptionClient.CloseAsync(); + await _sender.CloseAsync(); + await _receiver.CloseAsync(); + await _processor.CloseAsync(); } - private async Task MessageHandler(Message message, CancellationToken cancellationToken) + private async Task MessageHandler(ProcessMessageEventArgs args) { try { - var payload = Encoding.Default.GetString(message.Body); + var payload = args.Message.Body.ToString(); var evt = JsonConvert.DeserializeObject( payload, _serializerSettings); NotifySubscribers(evt); - await _subscriptionClient - .CompleteAsync(message.SystemProperties.LockToken) - .ConfigureAwait(false); + await _receiver.CompleteMessageAsync(args.Message); } catch { - await _subscriptionClient.AbandonAsync(message.SystemProperties.LockToken); + await _receiver.AbandonMessageAsync(args.Message); } } - private Task ExceptionHandler(ExceptionReceivedEventArgs arg) + private Task ExceptionHandler(ProcessErrorEventArgs arg) { _logger.LogWarning(default, arg.Exception, "Error on receiving events"); diff --git a/src/providers/WorkflowCore.Providers.Azure/WorkflowCore.Providers.Azure.csproj b/src/providers/WorkflowCore.Providers.Azure/WorkflowCore.Providers.Azure.csproj index cedd72024..65517764c 100644 --- a/src/providers/WorkflowCore.Providers.Azure/WorkflowCore.Providers.Azure.csproj +++ b/src/providers/WorkflowCore.Providers.Azure/WorkflowCore.Providers.Azure.csproj @@ -16,10 +16,10 @@ - - - - + + + + diff --git a/src/providers/WorkflowCore.Providers.Redis/README.md b/src/providers/WorkflowCore.Providers.Redis/README.md index 9edc8f31e..a8a610298 100644 --- a/src/providers/WorkflowCore.Providers.Redis/README.md +++ b/src/providers/WorkflowCore.Providers.Redis/README.md @@ -35,6 +35,6 @@ services.AddWorkflow(cfg => cfg.UseRedisPersistence("localhost:6379", "app-name"); cfg.UseRedisLocking("localhost:6379"); cfg.UseRedisQueues("localhost:6379", "app-name"); - cfg.UseRedisEventHub("localhost:6379", "channel-name") + cfg.UseRedisEventHub("localhost:6379", "channel-name"); }); ``` diff --git a/src/providers/WorkflowCore.Providers.Redis/Services/RedisPersistenceProvider.cs b/src/providers/WorkflowCore.Providers.Redis/Services/RedisPersistenceProvider.cs index 6bf8df875..eb76fa29e 100644 --- a/src/providers/WorkflowCore.Providers.Redis/Services/RedisPersistenceProvider.cs +++ b/src/providers/WorkflowCore.Providers.Redis/Services/RedisPersistenceProvider.cs @@ -92,6 +92,10 @@ public Task> GetWorkflowInstances(WorkflowStatus? public async Task GetWorkflowInstance(string Id, CancellationToken _ = default) { var raw = await _redis.HashGetAsync($"{_prefix}.{WORKFLOW_SET}", Id); + if (!raw.HasValue) + { + return null; + } return JsonConvert.DeserializeObject(raw, _serializerSettings); } diff --git a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/ServiceCollectionExtensions.cs b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/ServiceCollectionExtensions.cs index 4474e403a..33be17272 100644 --- a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/ServiceCollectionExtensions.cs +++ b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/ServiceCollectionExtensions.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection.Extensions; using WorkflowCore.Interface; using WorkflowCore.Models; @@ -10,7 +12,7 @@ namespace Microsoft.Extensions.DependencyInjection { - public delegate IConnection RabbitMqConnectionFactory(IServiceProvider sp, string clientProvidedName); + public delegate Task RabbitMqConnectionFactory(IServiceProvider sp, string clientProvidedName, CancellationToken cancellationToken = default); public static class ServiceCollectionExtensions { @@ -20,7 +22,7 @@ public static WorkflowOptions UseRabbitMQ(this WorkflowOptions options, IConnect if (connectionFactory == null) throw new ArgumentNullException(nameof(connectionFactory)); return options - .UseRabbitMQ((sp, name) => connectionFactory.CreateConnection(name)); + .UseRabbitMQ(async (sp, name, cancellationToken) => await connectionFactory.CreateConnectionAsync(name, cancellationToken)); } public static WorkflowOptions UseRabbitMQ(this WorkflowOptions options, @@ -32,7 +34,7 @@ public static WorkflowOptions UseRabbitMQ(this WorkflowOptions options, if (hostnames == null) throw new ArgumentNullException(nameof(hostnames)); return options - .UseRabbitMQ((sp, name) => connectionFactory.CreateConnection(hostnames.ToList(), name)); + .UseRabbitMQ(async (sp, name, cancellationToken) => await connectionFactory.CreateConnectionAsync(hostnames, name, cancellationToken)); } public static WorkflowOptions UseRabbitMQ(this WorkflowOptions options, RabbitMqConnectionFactory rabbitMqConnectionFactory) diff --git a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/Services/RabbitMQProvider.cs b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/Services/RabbitMQProvider.cs index 64322ad50..1554d8bed 100644 --- a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/Services/RabbitMQProvider.cs +++ b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/Services/RabbitMQProvider.cs @@ -37,11 +37,16 @@ public async Task QueueWork(string id, QueueType queue) if (_connection == null) throw new InvalidOperationException("RabbitMQ provider not running"); - using (var channel = _connection.CreateModel()) + var channel = await _connection.CreateChannelAsync(new CreateChannelOptions(publisherConfirmationsEnabled: false, publisherConfirmationTrackingEnabled: false), CancellationToken.None); + try { - channel.QueueDeclare(queue: _queueNameProvider.GetQueueName(queue), durable: true, exclusive: false, autoDelete: false, arguments: null); - var body = Encoding.UTF8.GetBytes(id); - channel.BasicPublish(exchange: "", routingKey: _queueNameProvider.GetQueueName(queue), basicProperties: null, body: body); + await channel.QueueDeclareAsync(queue: _queueNameProvider.GetQueueName(queue), durable: true, exclusive: false, autoDelete: false, arguments: null, passive: false, noWait: false, CancellationToken.None); + var body = new ReadOnlyMemory(Encoding.UTF8.GetBytes(id)); + await channel.BasicPublishAsync(exchange: "", routingKey: _queueNameProvider.GetQueueName(queue), mandatory: false, basicProperties: new BasicProperties(), body: body, CancellationToken.None); + } + finally + { + await channel.CloseAsync(200, "OK", abort: false, CancellationToken.None); } } @@ -50,25 +55,33 @@ public async Task DequeueWork(QueueType queue, CancellationToken cancell if (_connection == null) throw new InvalidOperationException("RabbitMQ provider not running"); - using (var channel = _connection.CreateModel()) + var channel = await _connection.CreateChannelAsync(new CreateChannelOptions(publisherConfirmationsEnabled: false, publisherConfirmationTrackingEnabled: false), CancellationToken.None); + try { - channel.QueueDeclare(queue: _queueNameProvider.GetQueueName(queue), - durable: true, - exclusive: false, - autoDelete: false, - arguments: null); + await channel.QueueDeclareAsync(queue: _queueNameProvider.GetQueueName(queue), + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + passive: false, + noWait: false, + CancellationToken.None); - channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, CancellationToken.None); - var msg = channel.BasicGet(_queueNameProvider.GetQueueName(queue), false); + var msg = await channel.BasicGetAsync(_queueNameProvider.GetQueueName(queue), autoAck: false, CancellationToken.None); if (msg != null) { - var data = Encoding.UTF8.GetString(msg.Body); - channel.BasicAck(msg.DeliveryTag, false); + var data = Encoding.UTF8.GetString(msg.Body.ToArray()); + await channel.BasicAckAsync(msg.DeliveryTag, multiple: false, CancellationToken.None); return data; } return null; } + finally + { + await channel.CloseAsync(200, "OK", abort: false, CancellationToken.None); + } } public void Dispose() @@ -76,20 +89,20 @@ public void Dispose() if (_connection != null) { if (_connection.IsOpen) - _connection.Close(); + _connection.CloseAsync(200, "OK", TimeSpan.FromSeconds(10), abort: false, CancellationToken.None).GetAwaiter().GetResult(); } } public async Task Start() { - _connection = _rabbitMqConnectionFactory(_serviceProvider, "Workflow-Core"); + _connection = await _rabbitMqConnectionFactory(_serviceProvider, "Workflow-Core"); } public async Task Stop() { if (_connection != null) { - _connection.Close(); + await _connection.CloseAsync(200, "OK", TimeSpan.FromSeconds(10), abort: false, CancellationToken.None); _connection = null; } } diff --git a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/WorkflowCore.QueueProviders.RabbitMQ.csproj b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/WorkflowCore.QueueProviders.RabbitMQ.csproj index 3ef1064ae..4729c7eeb 100644 --- a/src/providers/WorkflowCore.QueueProviders.RabbitMQ/WorkflowCore.QueueProviders.RabbitMQ.csproj +++ b/src/providers/WorkflowCore.QueueProviders.RabbitMQ/WorkflowCore.QueueProviders.RabbitMQ.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/providers/WorkflowCore.QueueProviders.SqlServer/Interfaces/ISqlCommandExecutor.cs b/src/providers/WorkflowCore.QueueProviders.SqlServer/Interfaces/ISqlCommandExecutor.cs index 68996f348..47e16c9ac 100644 --- a/src/providers/WorkflowCore.QueueProviders.SqlServer/Interfaces/ISqlCommandExecutor.cs +++ b/src/providers/WorkflowCore.QueueProviders.SqlServer/Interfaces/ISqlCommandExecutor.cs @@ -2,9 +2,9 @@ using System; using System.Data.Common; -using System.Data.SqlClient; using System.Linq; using System.Threading.Tasks; +using Microsoft.Data.SqlClient; #endregion diff --git a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlCommandExecutor.cs b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlCommandExecutor.cs index 5c77342fe..b44ea2b59 100644 --- a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlCommandExecutor.cs +++ b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlCommandExecutor.cs @@ -2,9 +2,9 @@ using System; using System.Data.Common; -using System.Data.SqlClient; using System.Linq; using System.Threading.Tasks; +using Microsoft.Data.SqlClient; using WorkflowCore.QueueProviders.SqlServer.Interfaces; #endregion @@ -18,11 +18,11 @@ public async Task ExecuteScalarAsync(SqlConnection cn, SqlTran using (var cmd = cn.CreateCommand()) { cmd.Transaction = tx; - cmd.CommandText = cmdtext; + cmd.CommandText = cmdtext; foreach (var param in parameters) cmd.Parameters.Add(param); - + return (TResult)await cmd.ExecuteScalarAsync(); } } diff --git a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProvider.cs b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProvider.cs index c07ed2b76..7a6044bbd 100644 --- a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProvider.cs +++ b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProvider.cs @@ -1,12 +1,12 @@ #region using using System; -using System.Data.SqlClient; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.Data.SqlClient; using WorkflowCore.Interface; using WorkflowCore.QueueProviders.SqlServer.Interfaces; @@ -123,11 +123,11 @@ public async Task DequeueWork(QueueType queue, CancellationToken cancell { await cn.OpenAsync(cancellationToken); - var par = _config.GetByQueue(queue); + var par = _config.GetByQueue(queue); var sql = _dequeueWorkCommand.Replace("{queueName}", par.QueueName); var msg = await _sqlCommandExecutor.ExecuteScalarAsync(cn, null, sql); return msg is DBNull ? null : (string)msg; - + } finally { diff --git a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProviderMigrator.cs b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProviderMigrator.cs index 18a27b920..cfed8a4c3 100644 --- a/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProviderMigrator.cs +++ b/src/providers/WorkflowCore.QueueProviders.SqlServer/Services/SqlServerQueueProviderMigrator.cs @@ -1,16 +1,16 @@ #region using using System; -using System.Data.SqlClient; using System.Linq; using System.Threading.Tasks; +using Microsoft.Data.SqlClient; using WorkflowCore.Interface; using WorkflowCore.QueueProviders.SqlServer.Interfaces; #endregion namespace WorkflowCore.QueueProviders.SqlServer.Services -{ +{ public class SqlServerQueueProviderMigrator : ISqlServerQueueProviderMigrator { @@ -54,7 +54,7 @@ public async Task MigrateDbAsync() await CreateService(cn, tx, item.InitiatorService, item.QueueName, item.ContractName); await CreateService(cn, tx, item.TargetService, item.QueueName, item.ContractName); } - + tx.Commit(); } catch @@ -75,7 +75,7 @@ private async Task CreateService(SqlConnection cn, SqlTransaction tx, string nam if (!string.IsNullOrEmpty(existing)) return; - + await _sqlCommandExecutor.ExecuteCommandAsync(cn, tx, $"CREATE SERVICE [{name}] ON QUEUE [{queueName}]([{contractName}]);"); } @@ -86,7 +86,7 @@ private async Task CreateQueue(SqlConnection cn, SqlTransaction tx, string queue if (!string.IsNullOrEmpty(existing)) return; - + await _sqlCommandExecutor.ExecuteCommandAsync(cn, tx, $"CREATE QUEUE [{queueName}];"); } @@ -97,7 +97,7 @@ private async Task CreateContract(SqlConnection cn, SqlTransaction tx, string co if (!string.IsNullOrEmpty(existing)) return; - + await _sqlCommandExecutor.ExecuteCommandAsync(cn, tx, $"CREATE CONTRACT [{contractName}] ( [{messageName}] SENT BY INITIATOR);"); } @@ -108,7 +108,7 @@ private async Task CreateMessageType(SqlConnection cn, SqlTransaction tx, string if (!string.IsNullOrEmpty(existing)) return; - + await _sqlCommandExecutor.ExecuteCommandAsync(cn, tx, $"CREATE MESSAGE TYPE [{message}] VALIDATION = NONE;"); } @@ -134,7 +134,7 @@ public async Task CreateDbAsync() dbPresente = (found != null); if (!dbPresente) - { + { var createCmd = cn.CreateCommand(); createCmd.CommandText = "create database [" + builder.InitialCatalog + "]"; await createCmd.ExecuteNonQueryAsync(); @@ -143,7 +143,7 @@ public async Task CreateDbAsync() finally { cn.Close(); - } + } await EnableBroker(masterCnStr, builder.InitialCatalog); } @@ -172,7 +172,7 @@ private async Task EnableBroker(string masterCn, string db) finally { cn.Close(); - } + } } } } \ No newline at end of file diff --git a/src/providers/WorkflowCore.QueueProviders.SqlServer/WorkflowCore.QueueProviders.SqlServer.csproj b/src/providers/WorkflowCore.QueueProviders.SqlServer/WorkflowCore.QueueProviders.SqlServer.csproj index c24b7d89e..d5d3144ba 100644 --- a/src/providers/WorkflowCore.QueueProviders.SqlServer/WorkflowCore.QueueProviders.SqlServer.csproj +++ b/src/providers/WorkflowCore.QueueProviders.SqlServer/WorkflowCore.QueueProviders.SqlServer.csproj @@ -21,8 +21,7 @@ - - + diff --git a/src/samples/Directory.Build.props b/src/samples/Directory.Build.props index 0f8bd594d..8e4a35270 100644 --- a/src/samples/Directory.Build.props +++ b/src/samples/Directory.Build.props @@ -1,6 +1,6 @@ - net6.0;netcoreapp3.1 + net6.0 latest false diff --git a/src/samples/WebApiSample/WebApiSample/Startup.cs b/src/samples/WebApiSample/WebApiSample/Startup.cs index 13a19ddd8..0eee8a49e 100644 --- a/src/samples/WebApiSample/WebApiSample/Startup.cs +++ b/src/samples/WebApiSample/WebApiSample/Startup.cs @@ -44,11 +44,14 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseDeveloperExceptionPage(); } - + app.UseRouting(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1")); - app.UseMvc(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute("default", "{controller=Workflows}/{action=Get}"); + }); var host = app.ApplicationServices.GetService(); host.RegisterWorkflow(); diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/.env.example b/src/samples/WorkflowCore.Sample.AzureFoundry/.env.example new file mode 100644 index 000000000..af86a4792 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/.env.example @@ -0,0 +1,25 @@ +# Azure AI Foundry Configuration +# Copy this file to .env and fill in your values + +# Azure AI Foundry / Azure OpenAI endpoint (required) +# Example: https://myresource.openai.azure.com +AZURE_AI_ENDPOINT= + +# API Key for authentication (required if not using Azure AD) +AZURE_AI_API_KEY= + +# Azure AI Foundry project name (optional) +AZURE_AI_PROJECT= + +# Default model for chat completions (optional, defaults to gpt-4o) +# Use your deployed model name, e.g., gpt-4o, gpt-35-turbo +AZURE_AI_DEFAULT_MODEL=gpt-4o + +# Default model for embeddings (optional) +AZURE_AI_EMBEDDING_MODEL=text-embedding-3-small + +# Azure AI Search endpoint (optional, for RAG/vector search) +AZURE_SEARCH_ENDPOINT= + +# Azure AI Search API key (optional) +AZURE_SEARCH_API_KEY= diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/.gitignore b/src/samples/WorkflowCore.Sample.AzureFoundry/.gitignore new file mode 100644 index 000000000..2eea525d8 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Program.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Program.cs new file mode 100644 index 000000000..1f4e37093 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Program.cs @@ -0,0 +1,238 @@ +using System; +using System.Threading.Tasks; +using DotNetEnv; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.ServiceExtensions; +using WorkflowCore.Interface; +using WorkflowCore.Sample.AzureFoundry.Tools; +using WorkflowCore.Sample.AzureFoundry.Workflows; + +namespace WorkflowCore.Sample.AzureFoundry +{ + public class Program + { + public static async Task Main(string[] args) + { + // Load environment variables from .env file + Env.Load(); + + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + + var serviceProvider = ConfigureServices(configuration); + + // Register tools + var toolRegistry = serviceProvider.GetRequiredService(); + toolRegistry.Register(serviceProvider.GetRequiredService()); + toolRegistry.Register(serviceProvider.GetRequiredService()); + + var host = serviceProvider.GetRequiredService(); + + // Register workflows + host.RegisterWorkflow(); + host.RegisterWorkflow(); + host.RegisterWorkflow(); + + host.Start(); + + Console.WriteLine("=== WorkflowCore Azure AI Foundry Sample ==="); + Console.WriteLine(); + Console.WriteLine("Choose a workflow to run:"); + Console.WriteLine("1. Simple Chat Completion"); + Console.WriteLine("2. Agent with Tools (Agentic Loop)"); + Console.WriteLine("3. Human-in-the-Loop Review"); + Console.WriteLine("Q. Quit"); + Console.WriteLine(); + + while (true) + { + Console.Write("Enter choice: "); + var choice = Console.ReadLine()?.Trim().ToUpper(); + + switch (choice) + { + case "1": + await RunSimpleChatWorkflow(host); + break; + case "2": + await RunAgentWithToolsWorkflow(host); + break; + case "3": + await RunHumanReviewWorkflow(host); + break; + case "Q": + host.Stop(); + return; + default: + Console.WriteLine("Invalid choice. Try again."); + break; + } + } + } + + private static async Task RunSimpleChatWorkflow(IWorkflowHost host) + { + Console.WriteLine("Type 'quit' to exit the conversation."); + Console.WriteLine(); + + while (true) + { + Console.Write("You: "); + var message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message) || message.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Exiting chat."); + break; + } + + var data = new ChatWorkflowData + { + UserMessage = message + }; + + var workflowId = await host.StartWorkflow("SimpleChatWorkflow", data); + + // Wait a bit for the workflow to complete + await Task.Delay(5000); + + var instance = await host.PersistenceStore.GetWorkflowInstance(workflowId); + var result = instance.Data as ChatWorkflowData; + + Console.WriteLine($"Assistant: {result?.Response ?? "Still processing..."}"); + Console.WriteLine(); + } + } + + private static async Task RunAgentWithToolsWorkflow(IWorkflowHost host) + { + Console.WriteLine("Available tools: weather (get weather for a city), calculator (do math)"); + Console.WriteLine("Type 'quit' to exit the conversation."); + Console.WriteLine(); + + while (true) + { + Console.Write("You: "); + var message = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(message) || message.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Exiting agent conversation."); + break; + } + + var data = new AgentWorkflowData + { + UserRequest = message + }; + + var workflowId = await host.StartWorkflow("AgentWithToolsWorkflow", data); + + // Wait for the agent loop to complete + await Task.Delay(15000); + + var instance = await host.PersistenceStore.GetWorkflowInstance(workflowId); + var result = instance.Data as AgentWorkflowData; + + Console.WriteLine($"Agent: {result?.AgentResponse ?? "Still processing..."}"); + Console.WriteLine(); + } + } + + private static async Task RunHumanReviewWorkflow(IWorkflowHost host) + { + Console.Write("Enter content to generate and review: "); + var topic = Console.ReadLine(); + + var data = new ReviewWorkflowData + { + Topic = topic, + Reviewer = "demo-user" + }; + + var workflowId = await host.StartWorkflow("HumanReviewWorkflow", data); + Console.WriteLine($"Started workflow: {workflowId}"); + + // Wait for AI to generate content + await Task.Delay(5000); + + var instance = await host.PersistenceStore.GetWorkflowInstance(workflowId); + var result = instance.Data as ReviewWorkflowData; + + Console.WriteLine(); + Console.WriteLine("=== Content Generated by AI ==="); + Console.WriteLine(result?.GeneratedContent ?? "Still generating..."); + Console.WriteLine("================================"); + Console.WriteLine(); + Console.WriteLine("To approve, publish a HumanReview event. For this demo, auto-approving..."); + + // In a real app, this would come from a UI or API + // For demo, we auto-approve + await host.PublishEvent( + "HumanReview", + $"{workflowId}.{GetReviewPointerId(instance)}", + new WorkflowCore.AI.AzureFoundry.Models.ReviewAction + { + Decision = WorkflowCore.AI.AzureFoundry.Models.ReviewDecision.Approved, + Reviewer = "demo-user" + }); + + await Task.Delay(2000); + + instance = await host.PersistenceStore.GetWorkflowInstance(workflowId); + result = instance.Data as ReviewWorkflowData; + + Console.WriteLine(); + Console.WriteLine($"Final approved content: {result?.ApprovedContent ?? "Pending..."}"); + Console.WriteLine(); + } + + private static string GetReviewPointerId(WorkflowCore.Models.WorkflowInstance instance) + { + string lastId = null; + foreach (var pointer in instance.ExecutionPointers) + { + if (pointer.StepName == "HumanReview") + return pointer.Id; + lastId = pointer.Id; + } + return lastId; + } + + private static IServiceProvider ConfigureServices(IConfiguration configuration) + { + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + services.AddWorkflow(); + + // Configure Azure AI Foundry + services.AddAzureFoundry(options => + { + options.Endpoint = configuration["AZURE_AI_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_AI_ENDPOINT not configured. Copy .env.example to .env and fill in values."); + options.ApiKey = configuration["AZURE_AI_API_KEY"]; + options.ProjectName = configuration["AZURE_AI_PROJECT"] ?? "default"; + options.DefaultModel = configuration["AZURE_AI_DEFAULT_MODEL"] ?? "gpt-4o"; + options.DefaultEmbeddingModel = configuration["AZURE_AI_EMBEDDING_MODEL"] ?? "text-embedding-3-small"; + options.SearchEndpoint = configuration["AZURE_SEARCH_ENDPOINT"]; + options.SearchApiKey = configuration["AZURE_SEARCH_API_KEY"]; + }); + + // Register tools + services.AddTransient(); + services.AddTransient(); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/README.md b/src/samples/WorkflowCore.Sample.AzureFoundry/README.md new file mode 100644 index 000000000..7fc1a9484 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/README.md @@ -0,0 +1,259 @@ +# WorkflowCore Azure AI Foundry Sample + +This sample demonstrates how to use the **WorkflowCore.AI.AzureFoundry** extension to build AI-powered, agentic workflows. + +## Features Demonstrated + +1. **Simple Chat Completion** - Conversational LLM chat with persistent conversation +2. **Agent with Tools** - Autonomous agent that uses tools (weather, calculator) to answer questions +3. **Human-in-the-Loop Review** - AI generates content, human approves/modifies before continuing + +## Prerequisites + +- .NET 8.0 or later +- Azure AI Foundry resource with deployed models (e.g., gpt-4o) +- API Key from your Azure AI resource + +## Setup + +1. **Copy the environment file:** + ```bash + cp .env.example .env + ``` + +2. **Edit `.env` with your Azure AI credentials:** + ```bash + AZURE_AI_ENDPOINT=https://your-resource.services.ai.azure.com + AZURE_AI_API_KEY=your-api-key-here + AZURE_AI_DEFAULT_MODEL=gpt-4o + ``` + + Get your endpoint and API key from the Azure Portal: + - Navigate to your Azure AI Foundry resource + - Go to **Keys and Endpoint** + - Copy the endpoint and one of the keys + +## Running the Sample + +```bash +cd src/samples/WorkflowCore.Sample.AzureFoundry +dotnet run +``` + +You'll see an interactive menu: + +``` +=== WorkflowCore Azure AI Foundry Sample === + +Choose a workflow to run: +1. Simple Chat Completion +2. Agent with Tools (Agentic Loop) +3. Human-in-the-Loop Review +Q. Quit + +Enter choice: +``` + +## Sample Workflows + +### 1. Simple Chat Completion + +A conversational chat loop where you can have a multi-turn conversation with the LLM. + +``` +Enter choice: 1 +Type 'quit' to exit the conversation. + +You: What is the capital of France? +Assistant: The capital of France is Paris. + +You: What's the population? +Assistant: Paris has a population of approximately 2.1 million in the city proper... + +You: quit +``` + +**Workflow code:** +```csharp +builder + .StartWith(context => ExecutionResult.Next()) + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a helpful assistant") + .UserMessage(data => data.UserMessage) + .OutputTo(data => data.Response)); +``` + +### 2. Agent with Tools (Agentic Loop) + +An autonomous agent that can use tools to accomplish tasks. The agent decides when and how to use tools based on your request. + +**Available tools:** +- `weather` - Get current weather for any city +- `calculator` - Perform mathematical calculations + +``` +Enter choice: 2 +Available tools: weather (get weather for a city), calculator (do math) +Type 'quit' to exit the conversation. + +You: What's the weather in Seattle? +Agent: The current weather in Seattle is partly cloudy with a temperature of 31°C (87°F) and a humidity of 84%. + +You: What is 25 * 4 + 10? +Agent: 25 × 4 + 10 = 110 + +You: What's the weather in Tokyo and convert the temperature from Celsius to Fahrenheit +Agent: The weather in Tokyo is sunny with a temperature of 28°C. Converting to Fahrenheit: (28 × 9/5) + 32 = 82.4°F + +You: quit +``` + +**How it works:** +1. You send a request +2. The LLM analyzes your request and decides which tool(s) to use +3. Tools are executed automatically +4. Results are fed back to the LLM +5. The LLM provides a final response using the tool results + +**Workflow code:** +```csharp +builder + .StartWith(context => ExecutionResult.Next()) + .AgentLoop(cfg => cfg + .SystemPrompt(@"You are a helpful assistant with access to tools. + Use the weather tool to get weather information. + Use the calculator tool for math operations. + Always explain what you're doing.") + .Message(data => data.UserRequest) + .WithTool("weather") + .WithTool("calculator") + .MaxIterations(5) + .AutoExecuteTools(true) + .OutputTo(data => data.AgentResponse)); +``` + +### 3. Human-in-the-Loop Review + +Demonstrates workflows that pause for human approval. The AI generates content, then waits for a human to approve, reject, or modify it. + +``` +Enter choice: 3 +Enter content to generate and review: Write a product description for wireless earbuds + +AI Generated Content: +[AI generates a product description] + +Enter your review decision: +1. Approve as-is +2. Approve with modifications +3. Reject + +Enter decision: 1 +Content approved: [approved content is stored] +``` + +**Workflow code:** +```csharp +builder + .StartWith(context => ExecutionResult.Next()) + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a marketing copywriter") + .UserMessage(data => $"Write about: {data.Topic}") + .OutputTo(data => data.GeneratedContent)) + .HumanReview(cfg => cfg + .Content(data => data.GeneratedContent) + .Reviewer(data => data.Reviewer) + .OnApproved(data => data.ApprovedContent)); +``` + +## Creating Custom Tools + +You can extend the agent's capabilities by creating custom tools: + +```csharp +public class StockPriceTool : IAgentTool +{ + public string Name => "stock_price"; + + public string Description => "Get the current stock price for a ticker symbol"; + + public string ParametersSchema => @"{ + ""type"": ""object"", + ""properties"": { + ""ticker"": { + ""type"": ""string"", + ""description"": ""Stock ticker symbol (e.g., MSFT, AAPL)"" + } + }, + ""required"": [""ticker""] + }"; + + private readonly IStockService _stockService; + + public StockPriceTool(IStockService stockService) + { + _stockService = stockService; + } + + public async Task ExecuteAsync( + string toolCallId, + string arguments, + CancellationToken ct) + { + var args = JsonSerializer.Deserialize(arguments); + var price = await _stockService.GetPriceAsync(args.Ticker, ct); + + return ToolResult.Succeeded(toolCallId, Name, + JsonSerializer.Serialize(new { ticker = args.Ticker, price = price })); + } +} +``` + +Register your tool: +```csharp +services.AddSingleton(); +toolRegistry.Register(serviceProvider.GetRequiredService()); +``` + +## Project Structure + +``` +WorkflowCore.Sample.AzureFoundry/ +├── Program.cs # Entry point and service configuration +├── README.md # This file +├── .env.example # Environment variable template +├── Workflows/ +│ ├── WorkflowData.cs # Data classes for all workflows +│ ├── SimpleChatWorkflow.cs # Simple LLM chat workflow +│ ├── AgentWithToolsWorkflow.cs # Agentic workflow with tool calling +│ └── HumanReviewWorkflow.cs # Human-in-the-loop workflow +└── Tools/ + ├── WeatherTool.cs # Simulated weather API tool + └── CalculatorTool.cs # Mathematical calculator tool +``` + +## Troubleshooting + +### "Resource not found" error + +Make sure your endpoint is correct: +- Azure AI Foundry: `https://your-resource.services.ai.azure.com` +- The model name should match a deployed model in your resource + +### Authentication errors + +1. Verify your API key is correct +2. Make sure the key has access to the resource +3. Check that the model is deployed and accessible + +### Tool not being called + +The LLM decides when to use tools based on your request. Try being more specific: +- ❌ "calculator" (too vague) +- ✅ "What is 25 + 15?" (clearly needs calculation) + +## Learn More + +- [WorkflowCore Documentation](https://workflow-core.readthedocs.io) +- [WorkflowCore.AI.AzureFoundry Extension](../../extensions/WorkflowCore.AI.AzureFoundry/) +- [Azure AI Foundry Documentation](https://learn.microsoft.com/azure/ai-services/) diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/CalculatorTool.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/CalculatorTool.cs new file mode 100644 index 000000000..9ddc2b082 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/CalculatorTool.cs @@ -0,0 +1,69 @@ +using System; +using System.Data; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.Sample.AzureFoundry.Tools +{ + /// + /// Sample tool that performs mathematical calculations + /// + public class CalculatorTool : IAgentTool + { + public string Name => "calculator"; + + public string Description => "Perform mathematical calculations. Supports basic arithmetic (+, -, *, /), parentheses, and common math operations."; + + public string ParametersSchema => @"{ + ""type"": ""object"", + ""properties"": { + ""expression"": { + ""type"": ""string"", + ""description"": ""The mathematical expression to evaluate (e.g., '2 + 2', '(10 * 5) / 2', '3.14 * 2')"" + } + }, + ""required"": [""expression""] + }"; + + public Task ExecuteAsync(string toolCallId, string arguments, CancellationToken cancellationToken = default) + { + try + { + var args = JsonSerializer.Deserialize(arguments, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + // Use DataTable.Compute for simple expression evaluation + var table = new DataTable(); + var result = table.Compute(args.Expression, null); + + var response = new + { + expression = args.Expression, + result = Convert.ToDouble(result) + }; + + return Task.FromResult(ToolResult.Succeeded( + toolCallId, + Name, + JsonSerializer.Serialize(response))); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Failed( + toolCallId, + Name, + $"Failed to evaluate expression: {ex.Message}")); + } + } + + private class CalculatorArgs + { + public string Expression { get; set; } + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/WeatherTool.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/WeatherTool.cs new file mode 100644 index 000000000..38da38a21 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Tools/WeatherTool.cs @@ -0,0 +1,71 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; + +namespace WorkflowCore.Sample.AzureFoundry.Tools +{ + /// + /// Sample tool that provides weather information (simulated) + /// + public class WeatherTool : IAgentTool + { + public string Name => "weather"; + + public string Description => "Get the current weather for a specified city. Returns temperature, conditions, and humidity."; + + public string ParametersSchema => @"{ + ""type"": ""object"", + ""properties"": { + ""city"": { + ""type"": ""string"", + ""description"": ""The city name to get weather for (e.g., 'London', 'New York')"" + } + }, + ""required"": [""city""] + }"; + + public Task ExecuteAsync(string toolCallId, string arguments, CancellationToken cancellationToken = default) + { + try + { + var args = JsonSerializer.Deserialize(arguments, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + // Simulated weather data + var random = new Random(); + var temp = random.Next(0, 35); + var conditions = new[] { "Sunny", "Cloudy", "Rainy", "Partly Cloudy", "Overcast" }; + var condition = conditions[random.Next(conditions.Length)]; + var humidity = random.Next(30, 90); + + var result = new + { + city = args.City, + temperature_celsius = temp, + temperature_fahrenheit = (temp * 9 / 5) + 32, + conditions = condition, + humidity_percent = humidity + }; + + return Task.FromResult(ToolResult.Succeeded( + toolCallId, + Name, + JsonSerializer.Serialize(result))); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Failed(toolCallId, Name, ex.Message)); + } + } + + private class WeatherArgs + { + public string City { get; set; } + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/WorkflowCore.Sample.AzureFoundry.csproj b/src/samples/WorkflowCore.Sample.AzureFoundry/WorkflowCore.Sample.AzureFoundry.csproj new file mode 100644 index 000000000..e23dcdf1f --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/WorkflowCore.Sample.AzureFoundry.csproj @@ -0,0 +1,39 @@ + + + + WorkflowCore.Sample.AzureFoundry + Exe + WorkflowCore.Sample.AzureFoundry + false + false + false + + + net8.0 + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/AgentWithToolsWorkflow.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/AgentWithToolsWorkflow.cs new file mode 100644 index 000000000..4a81051ab --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/AgentWithToolsWorkflow.cs @@ -0,0 +1,37 @@ +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; +using WorkflowCore.Sample.AzureFoundry.Tools; + +namespace WorkflowCore.Sample.AzureFoundry.Workflows +{ + /// + /// Workflow demonstrating an agentic loop with tool execution + /// + public class AgentWithToolsWorkflow : IWorkflow + { + public string Id => "AgentWithToolsWorkflow"; + public int Version => 1; + + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .AgentLoop(cfg => cfg + .SystemPrompt(@"You are a helpful assistant with access to tools. +Available tools: +- weather: Get current weather for a city +- calculator: Perform mathematical calculations + +Use the tools when needed to answer user questions accurately. +Always provide a final answer after using tools.") + .Message(data => data.UserRequest) + .WithTool("weather") + .WithTool("calculator") + .MaxIterations(5) + .AutoExecuteTools(true) + .OutputTo(data => data.AgentResponse)); + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/HumanReviewWorkflow.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/HumanReviewWorkflow.cs new file mode 100644 index 000000000..43edacef1 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/HumanReviewWorkflow.cs @@ -0,0 +1,40 @@ +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.Sample.AzureFoundry.Workflows +{ + /// + /// Workflow demonstrating human-in-the-loop review of AI-generated content + /// + public class HumanReviewWorkflow : IWorkflow + { + public string Id => "HumanReviewWorkflow"; + public int Version => 1; + + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + + // Step 1: Generate content with AI + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a content writer. Write clear, engaging content on the given topic.") + .UserMessage(data => $"Write a short paragraph about: {data.Topic}") + .MaxTokens(300) + .OutputTo(data => data.GeneratedContent)) + + // Step 2: Wait for human review + // Use CorrelationId to provide a known event key for completing the review + // If not provided, defaults to the workflowId + .HumanReview(cfg => cfg + .Content(data => data.GeneratedContent) + .Reviewer(data => data.Reviewer) + .Prompt("Please review this AI-generated content. Approve, modify, or reject.") + .CorrelationId(data => data.ReviewId) // Use custom correlation ID if provided + .OnApproved(data => data.ApprovedContent) + .OnEventKey(data => data.EventKey)); // Capture the event key for completing the review + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/SimpleChatWorkflow.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/SimpleChatWorkflow.cs new file mode 100644 index 000000000..a62e85107 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/SimpleChatWorkflow.cs @@ -0,0 +1,29 @@ +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Primitives; +using WorkflowCore.Interface; +using WorkflowCore.Models; + +namespace WorkflowCore.Sample.AzureFoundry.Workflows +{ + /// + /// Simple workflow demonstrating basic chat completion + /// + public class SimpleChatWorkflow : IWorkflow + { + public string Id => "SimpleChatWorkflow"; + public int Version => 1; + + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .ChatCompletion(cfg => cfg + .SystemPrompt("You are a helpful, friendly assistant. Keep responses concise.") + .UserMessage(data => data.UserMessage) + .Temperature(0.7f) + .MaxTokens(500) + .OutputTo(data => data.Response) + .OutputTokensTo(data => data.TokensUsed)); + } + } +} diff --git a/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/WorkflowData.cs b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/WorkflowData.cs new file mode 100644 index 000000000..b05b47f86 --- /dev/null +++ b/src/samples/WorkflowCore.Sample.AzureFoundry/Workflows/WorkflowData.cs @@ -0,0 +1,47 @@ +namespace WorkflowCore.Sample.AzureFoundry.Workflows +{ + /// + /// Data for simple chat workflow + /// + public class ChatWorkflowData + { + public string UserMessage { get; set; } + public string Response { get; set; } + public int TokensUsed { get; set; } + } + + /// + /// Data for agent with tools workflow + /// + public class AgentWorkflowData + { + public string UserRequest { get; set; } + public string AgentResponse { get; set; } + public int IterationsUsed { get; set; } + } + + /// + /// Data for human review workflow + /// + public class ReviewWorkflowData + { + public string Topic { get; set; } + public string Reviewer { get; set; } + public string GeneratedContent { get; set; } + public string ApprovedContent { get; set; } + public bool IsApproved { get; set; } + + /// + /// Optional custom correlation ID for the review. + /// If provided, this will be used as the event key. + /// If not provided, the workflow ID will be used. + /// + public string ReviewId { get; set; } + + /// + /// The event key to use when completing the review. + /// Use this with: workflowHost.PublishEvent("HumanReview", eventKey, reviewAction) + /// + public string EventKey { get; set; } + } +} diff --git a/src/samples/WorkflowCore.Sample08/README.md b/src/samples/WorkflowCore.Sample08/README.md new file mode 100644 index 000000000..7c6e83c51 --- /dev/null +++ b/src/samples/WorkflowCore.Sample08/README.md @@ -0,0 +1,71 @@ +# Human (User) Workflow Sample + +This sample demonstrates how to create workflows that require human interaction using the WorkflowCore.Users extension. + +## What this sample shows + +* **User Tasks**: How to create tasks that are assigned to specific users or groups +* **User Options**: How to provide multiple choice options for users to select from +* **Conditional Branching**: How to execute different workflow paths based on user choices +* **Task Escalation**: How to automatically reassign tasks to different users when timeouts occur +* **User Action Management**: How to retrieve open user actions and publish user responses programmatically + +## The Workflow + +```c# +public class HumanWorkflow : IWorkflow +{ + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(context => ExecutionResult.Next()) + .UserTask("Do you approve", data => @"domain\bob") + .WithOption("yes", "I approve").Do(then => then + .StartWith(context => Console.WriteLine("You approved")) + ) + .WithOption("no", "I do not approve").Do(then => then + .StartWith(context => Console.WriteLine("You did not approve")) + ) + .WithEscalation(x => TimeSpan.FromSeconds(20), x => @"domain\frank", action => action + .StartWith(context => Console.WriteLine("Escalated task")) + .Then(context => Console.WriteLine("Sending notification...")) + ) + .Then(context => Console.WriteLine("end")); + } +} +``` + +## How it works + +1. **Task Assignment**: The workflow creates a user task with the prompt "Do you approve" and assigns it to `domain\bob` + +2. **User Options**: Two options are provided: + - "yes" with label "I approve" - executes approval workflow + - "no" with label "I do not approve" - executes rejection workflow + +3. **Escalation**: If the task is not completed within 20 seconds, it automatically escalates to `domain\frank` and executes the escalation workflow + +4. **User Interaction**: The program demonstrates how to: + - Get open user actions using `host.GetOpenUserActions(workflowId)` + - Display options to the user + - Publish user responses using `host.PublishUserAction(key, user, value)` + +## Key Features + +* **UserTask**: Creates tasks that wait for human input +* **WithOption**: Defines multiple choice options with conditional workflow paths +* **WithEscalation**: Automatically reassigns tasks after a timeout period +* **Interactive Console**: Shows how to build a simple interface for user interaction + +## Dependencies + +This sample requires the `WorkflowCore.Users` extension package, which provides the human workflow capabilities. + +## Use Cases + +This pattern is useful for: +- Approval workflows +- Decision-making processes +- Task assignment and escalation +- Interactive business processes +- Multi-step user interactions \ No newline at end of file diff --git a/src/samples/WorkflowCore.Sample09s/Program.cs b/src/samples/WorkflowCore.Sample09s/Program.cs index 09e04abb8..2c60afe02 100644 --- a/src/samples/WorkflowCore.Sample09s/Program.cs +++ b/src/samples/WorkflowCore.Sample09s/Program.cs @@ -16,7 +16,7 @@ public static void Main(string[] args) host.Start(); Console.WriteLine("Starting workflow..."); - string workflowId = host.StartWorkflow("Foreach").Result; + string workflowId = host.StartWorkflow("ForeachSync").Result; Console.ReadLine(); @@ -41,4 +41,4 @@ private static IServiceProvider ConfigureServices() } } -} \ No newline at end of file +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3b2ad7341..cdb7e1676 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,24 +1,17 @@ - net6.0;netcoreapp3.1 + net6.0;net8.0 latest false - - - + + + - - - - - - - \ No newline at end of file diff --git a/test/ScratchPad/ScratchPad.csproj b/test/ScratchPad/ScratchPad.csproj index a194035c2..4e58ba3b1 100644 --- a/test/ScratchPad/ScratchPad.csproj +++ b/test/ScratchPad/ScratchPad.csproj @@ -8,6 +8,7 @@ false false false + net6.0;net8.0 diff --git a/test/WorkflowCore.AI.AzureFoundry.Tests/ConversationThreadTests.cs b/test/WorkflowCore.AI.AzureFoundry.Tests/ConversationThreadTests.cs new file mode 100644 index 000000000..ba86edb31 --- /dev/null +++ b/test/WorkflowCore.AI.AzureFoundry.Tests/ConversationThreadTests.cs @@ -0,0 +1,110 @@ +using WorkflowCore.AI.AzureFoundry.Models; +using Xunit; +using FluentAssertions; + +namespace WorkflowCore.AI.AzureFoundry.Tests +{ + public class ConversationThreadTests + { + [Fact] + public void AddMessage_ShouldUpdateTimestamp() + { + // Arrange + var thread = new ConversationThread(); + var originalUpdatedAt = thread.UpdatedAt; + + // Act + System.Threading.Thread.Sleep(10); + thread.AddUserMessage("Hello"); + + // Assert + thread.UpdatedAt.Should().BeAfter(originalUpdatedAt); + } + + [Fact] + public void AddSystemMessage_ShouldAddCorrectRole() + { + // Arrange + var thread = new ConversationThread(); + + // Act + thread.AddSystemMessage("You are helpful"); + + // Assert + thread.Messages.Should().ContainSingle(m => + m.Role == MessageRole.System && + m.Content == "You are helpful"); + } + + [Fact] + public void AddUserMessage_ShouldAddCorrectRole() + { + // Arrange + var thread = new ConversationThread(); + + // Act + thread.AddUserMessage("Hello"); + + // Assert + thread.Messages.Should().ContainSingle(m => + m.Role == MessageRole.User && + m.Content == "Hello"); + } + + [Fact] + public void AddAssistantMessage_ShouldAddCorrectRole() + { + // Arrange + var thread = new ConversationThread(); + + // Act + thread.AddAssistantMessage("Hi there!"); + + // Assert + thread.Messages.Should().ContainSingle(m => + m.Role == MessageRole.Assistant && + m.Content == "Hi there!"); + } + + [Fact] + public void AddToolMessage_ShouldAddCorrectRoleAndMetadata() + { + // Arrange + var thread = new ConversationThread(); + + // Act + thread.AddToolMessage("call-123", "search_tool", "results here"); + + // Assert + thread.Messages.Should().ContainSingle(m => + m.Role == MessageRole.Tool && + m.ToolCallId == "call-123" && + m.ToolName == "search_tool" && + m.Content == "results here"); + } + + [Fact] + public void AddMessage_WithTokenCount_ShouldUpdateTotalTokens() + { + // Arrange + var thread = new ConversationThread(); + + // Act + thread.AddMessage(new ConversationMessage + { + Role = MessageRole.User, + Content = "Hello", + TokenCount = 5 + }); + thread.AddMessage(new ConversationMessage + { + Role = MessageRole.Assistant, + Content = "Hi there!", + TokenCount = 10 + }); + + // Assert + thread.TotalTokens.Should().Be(15); + } + } +} diff --git a/test/WorkflowCore.AI.AzureFoundry.Tests/InMemoryConversationStoreTests.cs b/test/WorkflowCore.AI.AzureFoundry.Tests/InMemoryConversationStoreTests.cs new file mode 100644 index 000000000..1d7262598 --- /dev/null +++ b/test/WorkflowCore.AI.AzureFoundry.Tests/InMemoryConversationStoreTests.cs @@ -0,0 +1,87 @@ +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.AI.AzureFoundry.Services; +using Xunit; +using FluentAssertions; + +namespace WorkflowCore.AI.AzureFoundry.Tests +{ + public class InMemoryConversationStoreTests + { + private readonly InMemoryConversationStore _store; + + public InMemoryConversationStoreTests() + { + _store = new InMemoryConversationStore(); + } + + [Fact] + public async Task GetOrCreateThreadAsync_ShouldCreateNewThread() + { + // Act + var thread = await _store.GetOrCreateThreadAsync("workflow-1", "pointer-1"); + + // Assert + thread.Should().NotBeNull(); + thread.WorkflowInstanceId.Should().Be("workflow-1"); + thread.ExecutionPointerId.Should().Be("pointer-1"); + thread.Messages.Should().BeEmpty(); + } + + [Fact] + public async Task GetOrCreateThreadAsync_ShouldReturnExistingThread() + { + // Arrange + var firstThread = await _store.GetOrCreateThreadAsync("workflow-1", "pointer-1"); + firstThread.AddUserMessage("Hello"); + await _store.SaveThreadAsync(firstThread); + + // Act + var secondThread = await _store.GetOrCreateThreadAsync("workflow-1", "pointer-1"); + + // Assert + secondThread.Id.Should().Be(firstThread.Id); + secondThread.Messages.Should().HaveCount(1); + } + + [Fact] + public async Task SaveAndGetThread_ShouldPersistMessages() + { + // Arrange + var thread = new ConversationThread + { + WorkflowInstanceId = "workflow-2", + ExecutionPointerId = "pointer-2" + }; + thread.AddSystemMessage("You are a helpful assistant"); + thread.AddUserMessage("Hello"); + thread.AddAssistantMessage("Hi there!"); + + // Act + await _store.SaveThreadAsync(thread); + var retrieved = await _store.GetThreadAsync(thread.Id); + + // Assert + retrieved.Should().NotBeNull(); + retrieved.Messages.Should().HaveCount(3); + retrieved.Messages[0].Role.Should().Be(MessageRole.System); + retrieved.Messages[1].Role.Should().Be(MessageRole.User); + retrieved.Messages[2].Role.Should().Be(MessageRole.Assistant); + } + + [Fact] + public async Task DeleteThreadAsync_ShouldRemoveThread() + { + // Arrange + var thread = await _store.GetOrCreateThreadAsync("workflow-3", "pointer-3"); + await _store.SaveThreadAsync(thread); + + // Act + await _store.DeleteThreadAsync(thread.Id); + var retrieved = await _store.GetThreadAsync(thread.Id); + + // Assert + retrieved.Should().BeNull(); + } + } +} diff --git a/test/WorkflowCore.AI.AzureFoundry.Tests/ToolRegistryTests.cs b/test/WorkflowCore.AI.AzureFoundry.Tests/ToolRegistryTests.cs new file mode 100644 index 000000000..0f4b09d47 --- /dev/null +++ b/test/WorkflowCore.AI.AzureFoundry.Tests/ToolRegistryTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using WorkflowCore.AI.AzureFoundry.Interface; +using WorkflowCore.AI.AzureFoundry.Models; +using WorkflowCore.AI.AzureFoundry.Services; +using Xunit; +using FluentAssertions; + +namespace WorkflowCore.AI.AzureFoundry.Tests +{ + public class ToolRegistryTests + { + [Fact] + public void Register_ShouldAddToolToRegistry() + { + // Arrange + var registry = new ToolRegistry(null); + var tool = new TestTool(); + + // Act + registry.Register(tool); + + // Assert + registry.HasTool("test_tool").Should().BeTrue(); + registry.GetTool("test_tool").Should().Be(tool); + } + + [Fact] + public void GetTool_ShouldReturnNullForUnregisteredTool() + { + // Arrange + var registry = new ToolRegistry(null); + + // Act + var tool = registry.GetTool("nonexistent"); + + // Assert + tool.Should().BeNull(); + } + + [Fact] + public void GetAllTools_ShouldReturnAllRegisteredTools() + { + // Arrange + var registry = new ToolRegistry(null); + registry.Register(new TestTool()); + registry.Register(new AnotherTestTool()); + + // Act + var tools = registry.GetAllTools(); + + // Assert + tools.Should().HaveCount(2); + } + + [Fact] + public void GetToolDefinitions_ShouldReturnDefinitionsForAllTools() + { + // Arrange + var registry = new ToolRegistry(null); + registry.Register(new TestTool()); + + // Act + var definitions = registry.GetToolDefinitions(); + + // Assert + definitions.Should().ContainSingle(d => + d.Name == "test_tool" && + d.Description == "A test tool"); + } + + private class TestTool : IAgentTool + { + public string Name => "test_tool"; + public string Description => "A test tool"; + public string ParametersSchema => "{}"; + + public Task ExecuteAsync(string toolCallId, string arguments, CancellationToken cancellationToken = default) + { + return Task.FromResult(ToolResult.Succeeded(toolCallId, Name, "test result")); + } + } + + private class AnotherTestTool : IAgentTool + { + public string Name => "another_tool"; + public string Description => "Another test tool"; + public string ParametersSchema => "{}"; + + public Task ExecuteAsync(string toolCallId, string arguments, CancellationToken cancellationToken = default) + { + return Task.FromResult(ToolResult.Succeeded(toolCallId, Name, "another result")); + } + } + } +} diff --git a/test/WorkflowCore.AI.AzureFoundry.Tests/WorkflowCore.AI.AzureFoundry.Tests.csproj b/test/WorkflowCore.AI.AzureFoundry.Tests/WorkflowCore.AI.AzureFoundry.Tests.csproj new file mode 100644 index 000000000..46cc5e004 --- /dev/null +++ b/test/WorkflowCore.AI.AzureFoundry.Tests/WorkflowCore.AI.AzureFoundry.Tests.csproj @@ -0,0 +1,21 @@ + + + + WorkflowCore.AI.AzureFoundry.Tests + WorkflowCore.AI.AzureFoundry.Tests + true + false + false + false + + + net8.0 + true + + + + + + + + diff --git a/test/WorkflowCore.IntegrationTests/Scenarios/ActivityScenario.cs b/test/WorkflowCore.IntegrationTests/Scenarios/ActivityScenario.cs index 1e082f4db..a5819d2fa 100644 --- a/test/WorkflowCore.IntegrationTests/Scenarios/ActivityScenario.cs +++ b/test/WorkflowCore.IntegrationTests/Scenarios/ActivityScenario.cs @@ -43,7 +43,7 @@ public void Build(IWorkflowBuilder builder) public ActivityScenario() { - Setup(); + Setup(); //NOTE cjundt [10/03/2023] setup shouldn't be here in constructor. It prevents from using ICollectionFixture data. } [Fact] diff --git a/test/WorkflowCore.IntegrationTests/Scenarios/StopAsyncScenario.cs b/test/WorkflowCore.IntegrationTests/Scenarios/StopAsyncScenario.cs new file mode 100644 index 000000000..7339f736e --- /dev/null +++ b/test/WorkflowCore.IntegrationTests/Scenarios/StopAsyncScenario.cs @@ -0,0 +1,97 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using WorkflowCore.Interface; +using WorkflowCore.Models; +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace WorkflowCore.IntegrationTests.Scenarios +{ + public class StopAsyncWorkflow : IWorkflow + { + internal static DateTime? StepStartTime = null; + internal static DateTime? StepEndTime = null; + + public string Id => "StopAsyncWorkflow"; + public int Version => 1; + public void Build(IWorkflowBuilder builder) + { + builder + .StartWith(); + } + } + + internal class LongRunningStep : StepBodyAsync + { + public override async Task RunAsync(IStepExecutionContext context) + { + StopAsyncWorkflow.StepStartTime = DateTime.Now; + await Task.Delay(5000); // 5 second delay + StopAsyncWorkflow.StepEndTime = DateTime.Now; + return ExecutionResult.Next(); + } + } + + public class StopAsyncScenario : IDisposable + { + protected IWorkflowHost Host; + protected IPersistenceProvider PersistenceProvider; + + public StopAsyncScenario() + { + //setup dependency injection + IServiceCollection services = new ServiceCollection(); + services.AddLogging(); + services.AddWorkflow(options => options.UsePollInterval(TimeSpan.FromSeconds(3))); + + var serviceProvider = services.BuildServiceProvider(); + + PersistenceProvider = serviceProvider.GetService(); + Host = serviceProvider.GetService(); + Host.RegisterWorkflow(); + Host.Start(); + } + + [Fact] + public async Task StopAsync_should_wait_for_running_steps_to_complete() + { + // Arrange + StopAsyncWorkflow.StepStartTime = null; + StopAsyncWorkflow.StepEndTime = null; + + // Start a workflow with a long-running step + var workflowId = await Host.StartWorkflow("StopAsyncWorkflow", null); + + // Wait for the step to start executing + var waitCount = 0; + while (StopAsyncWorkflow.StepStartTime == null && waitCount < 50) + { + await Task.Delay(100); + waitCount++; + } + + StopAsyncWorkflow.StepStartTime.Should().NotBeNull("the step should have started before stopping"); + StopAsyncWorkflow.StepEndTime.Should().BeNull("the step should still be running"); + + // Act - Call StopAsync which should wait for the step to complete + var stopwatch = Stopwatch.StartNew(); + await Host.StopAsync(default); + stopwatch.Stop(); + + // Assert + // The step should have completed + StopAsyncWorkflow.StepEndTime.Should().NotBeNull("the step should have completed before StopAsync returned"); + + // StopAsync should have taken at least 3 seconds (the remaining delay time) + stopwatch.ElapsedMilliseconds.Should().BeGreaterOrEqualTo(3000, + "StopAsync should wait for the running step to complete"); + } + + public void Dispose() + { + // Dispose is intentionally empty to avoid double-stop + } + } +} diff --git a/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj b/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj index bdc973728..c92182936 100644 --- a/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj +++ b/test/WorkflowCore.IntegrationTests/WorkflowCore.IntegrationTests.csproj @@ -7,6 +7,7 @@ false false false + net6.0 diff --git a/test/WorkflowCore.Tests.DynamoDB/DynamoPersistenceProviderFixture.cs b/test/WorkflowCore.Tests.DynamoDB/DynamoPersistenceProviderFixture.cs index 6fb9c1ea9..7d215f4ee 100644 --- a/test/WorkflowCore.Tests.DynamoDB/DynamoPersistenceProviderFixture.cs +++ b/test/WorkflowCore.Tests.DynamoDB/DynamoPersistenceProviderFixture.cs @@ -26,8 +26,9 @@ protected override IPersistenceProvider Subject if (_subject == null) { var cfg = new AmazonDynamoDBConfig { ServiceURL = DynamoDbDockerSetup.ConnectionString }; - var provisioner = new DynamoDbProvisioner(DynamoDbDockerSetup.Credentials, cfg, "unittests", new LoggerFactory()); - var client = new DynamoPersistenceProvider(DynamoDbDockerSetup.Credentials, cfg, provisioner, "unittests", new LoggerFactory()); + var dbClient = new AmazonDynamoDBClient(DynamoDbDockerSetup.Credentials, cfg); + var provisioner = new DynamoDbProvisioner(dbClient, "unittests", new LoggerFactory()); + var client = new DynamoPersistenceProvider(dbClient, provisioner, "unittests", new LoggerFactory()); client.EnsureStoreExists(); _subject = client; } diff --git a/test/WorkflowCore.Tests.DynamoDB/WorkflowCore.Tests.DynamoDB.csproj b/test/WorkflowCore.Tests.DynamoDB/WorkflowCore.Tests.DynamoDB.csproj index 65320489d..6de478177 100644 --- a/test/WorkflowCore.Tests.DynamoDB/WorkflowCore.Tests.DynamoDB.csproj +++ b/test/WorkflowCore.Tests.DynamoDB/WorkflowCore.Tests.DynamoDB.csproj @@ -1,5 +1,9 @@ + + net6.0 + + diff --git a/test/WorkflowCore.Tests.Elasticsearch/WorkflowCore.Tests.Elasticsearch.csproj b/test/WorkflowCore.Tests.Elasticsearch/WorkflowCore.Tests.Elasticsearch.csproj index 9aa005f40..d5bae6c71 100644 --- a/test/WorkflowCore.Tests.Elasticsearch/WorkflowCore.Tests.Elasticsearch.csproj +++ b/test/WorkflowCore.Tests.Elasticsearch/WorkflowCore.Tests.Elasticsearch.csproj @@ -1,5 +1,9 @@  + + net6.0 + + diff --git a/test/WorkflowCore.Tests.MongoDB/MongoDockerSetup.cs b/test/WorkflowCore.Tests.MongoDB/MongoDockerSetup.cs index df281ce29..e5c18b341 100644 --- a/test/WorkflowCore.Tests.MongoDB/MongoDockerSetup.cs +++ b/test/WorkflowCore.Tests.MongoDB/MongoDockerSetup.cs @@ -1,25 +1,30 @@ using System; using System.Threading.Tasks; using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; using Squadron; +using WorkflowCore.UnitTests; using Xunit; namespace WorkflowCore.Tests.MongoDB { public class MongoDockerSetup : IAsyncLifetime { - private readonly MongoResource _mongoResource; + private readonly MongoReplicaSetResource _mongoResource; public static string ConnectionString { get; set; } public MongoDockerSetup() { - _mongoResource = new MongoResource(); + _mongoResource = new MongoReplicaSetResource(); } public async Task InitializeAsync() { await _mongoResource.InitializeAsync(); ConnectionString = _mongoResource.ConnectionString; + BsonSerializer.TryRegisterSerializer(new ObjectSerializer(type => + ObjectSerializer.DefaultAllowedTypes(type) || type.FullName.StartsWith("WorkflowCore."))); } public Task DisposeAsync() diff --git a/test/WorkflowCore.Tests.MongoDB/MongoPersistenceProviderFixture.cs b/test/WorkflowCore.Tests.MongoDB/MongoPersistenceProviderFixture.cs index c61b4b5e2..fd6546aca 100644 --- a/test/WorkflowCore.Tests.MongoDB/MongoPersistenceProviderFixture.cs +++ b/test/WorkflowCore.Tests.MongoDB/MongoPersistenceProviderFixture.cs @@ -1,5 +1,4 @@ using MongoDB.Driver; -using System; using WorkflowCore.Interface; using WorkflowCore.Persistence.MongoDB.Services; using WorkflowCore.UnitTests; @@ -23,7 +22,9 @@ protected override IPersistenceProvider Subject { var client = new MongoClient(MongoDockerSetup.ConnectionString); var db = client.GetDatabase(nameof(MongoPersistenceProviderFixture)); - return new MongoPersistenceProvider(db); + var provider = new MongoPersistenceProvider(db); + provider.EnsureStoreExists(); + return provider; } } } diff --git a/test/WorkflowCore.Tests.MongoDB/WorkflowCore.Tests.MongoDB.csproj b/test/WorkflowCore.Tests.MongoDB/WorkflowCore.Tests.MongoDB.csproj index 68251d271..0bffc02e8 100644 --- a/test/WorkflowCore.Tests.MongoDB/WorkflowCore.Tests.MongoDB.csproj +++ b/test/WorkflowCore.Tests.MongoDB/WorkflowCore.Tests.MongoDB.csproj @@ -7,6 +7,7 @@ false false false + net8.0 @@ -20,8 +21,8 @@ - - + + diff --git a/test/WorkflowCore.Tests.MySQL/WorkflowCore.Tests.MySQL.csproj b/test/WorkflowCore.Tests.MySQL/WorkflowCore.Tests.MySQL.csproj index a56acc9b7..bdb4029e7 100644 --- a/test/WorkflowCore.Tests.MySQL/WorkflowCore.Tests.MySQL.csproj +++ b/test/WorkflowCore.Tests.MySQL/WorkflowCore.Tests.MySQL.csproj @@ -1,5 +1,9 @@ + + net6.0;net8.0 + + diff --git a/test/WorkflowCore.Tests.Oracle/OraclePersistenceProviderFixture.cs b/test/WorkflowCore.Tests.Oracle/OraclePersistenceProviderFixture.cs new file mode 100644 index 000000000..a87c11bad --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/OraclePersistenceProviderFixture.cs @@ -0,0 +1,23 @@ +using WorkflowCore.Interface; +using WorkflowCore.Persistence.EntityFramework.Services; +using WorkflowCore.Persistence.Oracle; +using WorkflowCore.UnitTests; +using Xunit; +using Xunit.Abstractions; + +namespace WorkflowCore.Tests.Oracle +{ + [Collection("Oracle collection")] + public class OraclePersistenceProviderFixture : BasePersistenceFixture + { + private readonly EntityFrameworkPersistenceProvider _subject; + protected override IPersistenceProvider Subject => _subject; + + public OraclePersistenceProviderFixture(OracleDockerSetup dockerSetup, ITestOutputHelper output) + { + output.WriteLine($"Connecting on {OracleDockerSetup.ConnectionString}"); + _subject = new EntityFrameworkPersistenceProvider(new OracleContextFactory(OracleDockerSetup.ConnectionString), true, true); + _subject.EnsureStoreExists(); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/OracleSetup.cs b/test/WorkflowCore.Tests.Oracle/OracleSetup.cs new file mode 100644 index 000000000..a5ee262ec --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/OracleSetup.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Testcontainers.Oracle; +using Xunit; + +namespace WorkflowCore.Tests.Oracle +{ + public class OracleDockerSetup : IAsyncLifetime + { + private readonly OracleContainer _oracleContainer; + + public static string ConnectionString { get; private set; } + + public OracleDockerSetup() + { + _oracleContainer = new OracleBuilder() + .WithImage("gvenzl/oracle-free:latest") + .WithUsername("TEST_WF") + .WithPassword("test") + .Build(); + } + + public async Task InitializeAsync() + { + await _oracleContainer.StartAsync(); + // Build connection string manually since TestContainers might not provide Oracle-specific format + ConnectionString = $"Data Source=localhost:{_oracleContainer.GetMappedPublicPort(1521)}/FREEPDB1;User Id=TEST_WF;Password=test;"; + } + + public async Task DisposeAsync() => await _oracleContainer.DisposeAsync(); + } + + [CollectionDefinition("Oracle collection")] + public class OracleCollection : ICollectionFixture { } +} \ No newline at end of file diff --git a/test/WorkflowCore.Tests.Oracle/Properties/AssemblyInfo.cs b/test/WorkflowCore.Tests.Oracle/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..24072c66f --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Properties/AssemblyInfo.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("WorkflowCore.Tests.Oracle")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("8c2bd4d2-43ec-4930-9364-cda938c01803")] diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleActivityScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleActivityScenario.cs new file mode 100644 index 000000000..7bf8ae2d6 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleActivityScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleActivityScenario : ActivityScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleBasicScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleBasicScenario.cs new file mode 100644 index 000000000..a7f8bb940 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleBasicScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleBasicScenario : BasicScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDataScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDataScenario.cs new file mode 100644 index 000000000..136b54a3e --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDataScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleDataScenario : DataIOScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDelayScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDelayScenario.cs new file mode 100644 index 000000000..22a582f0d --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDelayScenario.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleDelayScenario : DelayScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(cfg => + { + cfg.UseOracle(OracleDockerSetup.ConnectionString, true, true); + cfg.UsePollInterval(TimeSpan.FromSeconds(2)); + }); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDynamicDataScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDynamicDataScenario.cs new file mode 100644 index 000000000..5ab8b940b --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleDynamicDataScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleDynamicDataScenario : DynamicDataIOScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleEventScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleEventScenario.cs new file mode 100644 index 000000000..a38290065 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleEventScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleEventScenario : EventScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForeachScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForeachScenario.cs new file mode 100644 index 000000000..023920ba1 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForeachScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleForeachScenario : ForeachScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForkScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForkScenario.cs new file mode 100644 index 000000000..bbe85a909 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleForkScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleForkScenario : ForkScenario + { + protected override void Configure(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleIfScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleIfScenario.cs new file mode 100644 index 000000000..cab8004bd --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleIfScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleIfScenario : IfScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleRetrySagaScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleRetrySagaScenario.cs new file mode 100644 index 000000000..21bf5dbef --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleRetrySagaScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleRetrySagaScenario : RetrySagaScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleSagaScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleSagaScenario.cs new file mode 100644 index 000000000..ea093b222 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleSagaScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleSagaScenario : SagaScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleUserScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleUserScenario.cs new file mode 100644 index 000000000..60ab568b2 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleUserScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleUserScenario : UserScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhenScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhenScenario.cs new file mode 100644 index 000000000..d12490f59 --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhenScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleWhenScenario : WhenScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhileScenario.cs b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhileScenario.cs new file mode 100644 index 000000000..45f5eebdd --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/Scenarios/OracleWhileScenario.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using WorkflowCore.IntegrationTests.Scenarios; +using WorkflowCore.Persistence.Oracle; + +using Xunit; + +namespace WorkflowCore.Tests.Oracle.Scenarios +{ + [Collection("Oracle collection")] + public class OracleWhileScenario : WhileScenario + { + protected override void ConfigureServices(IServiceCollection services) + { + services.AddWorkflow(x => x.UseOracle(OracleDockerSetup.ConnectionString, true, true)); + } + } +} diff --git a/test/WorkflowCore.Tests.Oracle/WorkflowCore.Tests.Oracle.csproj b/test/WorkflowCore.Tests.Oracle/WorkflowCore.Tests.Oracle.csproj new file mode 100644 index 000000000..b3b68406c --- /dev/null +++ b/test/WorkflowCore.Tests.Oracle/WorkflowCore.Tests.Oracle.csproj @@ -0,0 +1,25 @@ + + + + net6.0;net8.0 + WorkflowCore.Tests.Oracle + WorkflowCore.Tests.Oracle + true + false + false + false + + + + + + + + + + + + + + + diff --git a/test/WorkflowCore.Tests.PostgreSQL/WorkflowCore.Tests.PostgreSQL.csproj b/test/WorkflowCore.Tests.PostgreSQL/WorkflowCore.Tests.PostgreSQL.csproj index 66cc649b4..53a91aa9a 100644 --- a/test/WorkflowCore.Tests.PostgreSQL/WorkflowCore.Tests.PostgreSQL.csproj +++ b/test/WorkflowCore.Tests.PostgreSQL/WorkflowCore.Tests.PostgreSQL.csproj @@ -7,6 +7,7 @@ false false false + net6.0;net8.0 diff --git a/test/WorkflowCore.Tests.QueueProviders.RabbitMQ/WorkflowCore.Tests.QueueProviders.RabbitMQ.csproj b/test/WorkflowCore.Tests.QueueProviders.RabbitMQ/WorkflowCore.Tests.QueueProviders.RabbitMQ.csproj index 98e658fed..40ba780ac 100644 --- a/test/WorkflowCore.Tests.QueueProviders.RabbitMQ/WorkflowCore.Tests.QueueProviders.RabbitMQ.csproj +++ b/test/WorkflowCore.Tests.QueueProviders.RabbitMQ/WorkflowCore.Tests.QueueProviders.RabbitMQ.csproj @@ -1,5 +1,9 @@ + + net8.0 + + diff --git a/test/WorkflowCore.Tests.Redis/WorkflowCore.Tests.Redis.csproj b/test/WorkflowCore.Tests.Redis/WorkflowCore.Tests.Redis.csproj index 147dad6da..f6a8aaed1 100644 --- a/test/WorkflowCore.Tests.Redis/WorkflowCore.Tests.Redis.csproj +++ b/test/WorkflowCore.Tests.Redis/WorkflowCore.Tests.Redis.csproj @@ -1,5 +1,9 @@ + + net6.0 + + diff --git a/test/WorkflowCore.Tests.SqlServer/WorkflowCore.Tests.SqlServer.csproj b/test/WorkflowCore.Tests.SqlServer/WorkflowCore.Tests.SqlServer.csproj index 24e83536e..11b6c00e2 100644 --- a/test/WorkflowCore.Tests.SqlServer/WorkflowCore.Tests.SqlServer.csproj +++ b/test/WorkflowCore.Tests.SqlServer/WorkflowCore.Tests.SqlServer.csproj @@ -1,5 +1,9 @@  + + net6.0;net8.0;net9.0 + + diff --git a/test/WorkflowCore.Tests.Sqlite/WorkflowCore.Tests.Sqlite.csproj b/test/WorkflowCore.Tests.Sqlite/WorkflowCore.Tests.Sqlite.csproj index 63acee283..e3e981713 100644 --- a/test/WorkflowCore.Tests.Sqlite/WorkflowCore.Tests.Sqlite.csproj +++ b/test/WorkflowCore.Tests.Sqlite/WorkflowCore.Tests.Sqlite.csproj @@ -1,5 +1,9 @@  + + net6.0;net8.0 + + diff --git a/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs b/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs index 01a743e8a..c47ba054b 100644 --- a/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs +++ b/test/WorkflowCore.UnitTests/Services/DefinitionStorage/DefinitionLoaderTests.cs @@ -19,7 +19,7 @@ public class DefinitionLoaderTests public DefinitionLoaderTests() { _registry = A.Fake(); - _subject = new DefinitionLoader(_registry); + _subject = new DefinitionLoader(_registry, new TypeResolver()); } [Fact(DisplayName = "Should register workflow")] diff --git a/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj b/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj index 3ca6d6038..ff8f2e19e 100644 --- a/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj +++ b/test/WorkflowCore.UnitTests/WorkflowCore.UnitTests.csproj @@ -7,6 +7,7 @@ false false false + net6.0