diff --git a/doc/integration-program/extensions-catchup-dispositions.json b/doc/integration-program/extensions-catchup-dispositions.json new file mode 100644 index 0000000000..a52e4fc6d9 --- /dev/null +++ b/doc/integration-program/extensions-catchup-dispositions.json @@ -0,0 +1,489 @@ +{ + "priorSourceCommit": "3cf502955791cbd20375022ef4597f0e195faf8a", + "reconciledSourceCommit": "a24de2986e0a02c335bdd3f86e47bd049949d3b4", + "coreBaseCommit": "91a1c07232e806436c5e759086bbb9b0aee92234", + "changedPaths": 48, + "records": [ + { + "source": ".github/reviewers.md", + "destination": "doc/integration-program/legacy/extensions/.github/reviewers.md.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "bcf3c312791caec265480938591873b9826113b5", + "destinationBlob": "bcf3c312791caec265480938591873b9826113b5", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": ".github/workflows/packages.yml", + "destination": "doc/integration-program/legacy/extensions/.github/workflows/packages.yml.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "4763f3a806985edb0f5e690b048094fb77e192a9", + "destinationBlob": "4763f3a806985edb0f5e690b048094fb77e192a9", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "AGENTS.md", + "destination": "doc/integration-program/legacy/extensions/AGENTS.md.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "e12665606dd6a24702150e7d7009cd9df71622d3", + "destinationBlob": "e12665606dd6a24702150e7d7009cd9df71622d3", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "Directory.Build.props", + "destination": "doc/integration-program/legacy/extensions/Directory.Build.props.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "1150b573e0b7993d4d8762d4b867054a10741812", + "destinationBlob": "1150b573e0b7993d4d8762d4b867054a10741812", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "Directory.Packages.props", + "destination": "doc/integration-program/legacy/extensions/Directory.Packages.props.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "ae4204b424431a7521f078f9f62be373612ecc4c", + "destinationBlob": "ae4204b424431a7521f078f9f62be373612ecc4c", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "Elsa.Extensions.sln", + "destination": "doc/integration-program/legacy/extensions/Elsa.Extensions.sln.source", + "disposition": "refreshed-inert-source", + "reason": "Updated source snapshot remains inert; live Core configuration preserved.", + "sourceBlob": "42aace26dfd34157a608d8ec1ec8fe8570a21506", + "destinationBlob": "42aace26dfd34157a608d8ec1ec8fe8570a21506", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj", + "disposition": "already-identical", + "reason": "Preserve in-repository project references and Core-only SSH.NET compatibility pin; Dapper test friend assemblies already present.", + "sourceBlob": "2754df91588c9ca5ed5ffaaa522598cb220283cb", + "destinationBlob": "2754df91588c9ca5ed5ffaaa522598cb220283cb", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "0ca45c966205499217726fc49674a314da18d33a", + "destinationBlob": "0ca45c966205499217726fc49674a314da18d33a", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs", + "disposition": "preserved-core-adaptation", + "reason": "Source provider arrays are already included; Core also requires QuotedIdentifiers/UnquotedIdentifiers for Identity V3_10.", + "sourceBlob": "b0a5e00428dcb5d322882d8fd59a83aa8c55e129", + "destinationBlob": "db445d0be7998fc56ba652d394fdbff7fbaede4c", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "7515ba24f736b1e17c8263a92232174b6d9a4998", + "destinationBlob": "7515ba24f736b1e17c8263a92232174b6d9a4998", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "58af18ad6c6d79d43a2f27913acedfa5f33429e5", + "destinationBlob": "58af18ad6c6d79d43a2f27913acedfa5f33429e5", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "22f0bf3b5dcc02f017cb2b5e28b539eecc3543be", + "destinationBlob": "22f0bf3b5dcc02f017cb2b5e28b539eecc3543be", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs", + "disposition": "preserved-core-adaptation", + "reason": "Behavior identical; preserve Core final newline.", + "sourceBlob": "d5100e3cadb3ad7ee8b6d5ad90c7562aa74d279a", + "destinationBlob": "bfd03a6cf7a4e0692316fd1000d8ae42aff6f2bb", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs", + "disposition": "preserved-core-adaptation", + "reason": "Behavior identical; preserve Core final newline.", + "sourceBlob": "e607b11fc841465a6ba4bdd5d6cb4a9513bab7a4", + "destinationBlob": "402bb001b2dcf95d3d6961cf7c2162a7bceff3bb", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "2dbc57df34ddd150949f6c534af14701de999a94", + "destinationBlob": "2dbc57df34ddd150949f6c534af14701de999a94", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "926c363d7a2c055170bdbc29c8e07a59a7fd232a", + "destinationBlob": "926c363d7a2c055170bdbc29c8e07a59a7fd232a", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "df4745158edd0f25a671763a1aaae346df14f9a4", + "destinationBlob": "df4745158edd0f25a671763a1aaae346df14f9a4", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs", + "disposition": "imported-with-core-adaptation", + "reason": "Atomic method imported; Core API already provides TryDeleteAsync, so tests exercise interface dispatch and documentation drops obsolete external preview dependency caveat.", + "sourceBlob": "6b8b03783a3805152892cc9e6d83dfea5fe74e59", + "destinationBlob": "7e00e3e4cd6e67204595e5e7747eafa07a9bdec6", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "16829755b82c139ecaa8d4d4301cf356254fe211", + "destinationBlob": "16829755b82c139ecaa8d4d4301cf356254fe211", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/Services/Store.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/Services/Store.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "fe0857d2d8014f14a7b1ef6ec7923083967f163e", + "destinationBlob": "fe0857d2d8014f14a7b1ef6ec7923083967f163e", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "3315e29fa0f4bab38f56ee1728919d2a61f06a06", + "destinationBlob": "3315e29fa0f4bab38f56ee1728919d2a61f06a06", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "df858189ad1a64ff82ed532cb924aa773798771c", + "destinationBlob": "df858189ad1a64ff82ed532cb924aa773798771c", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "13a71994c212d8bde4ad726cac0d25e884c5350c", + "destinationBlob": "13a71994c212d8bde4ad726cac0d25e884c5350c", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "a6b540a385e3392778dd7148907b373728611d00", + "destinationBlob": "a6b540a385e3392778dd7148907b373728611d00", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "b025fbbfad8787d872fbb32511d9e1e2b9131958", + "destinationBlob": "b025fbbfad8787d872fbb32511d9e1e2b9131958", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs", + "disposition": "imported-with-core-adaptation", + "reason": "Atomic method imported; Core API already provides TryDeleteAsync, so tests exercise interface dispatch and documentation drops obsolete external preview dependency caveat.", + "sourceBlob": "b3de372b2ca6e51ebb510c686a4c0b2592befba1", + "destinationBlob": "0c198d26beb70d6ecbd27614c38341900ab7a881", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "d904082b351f1d9519d33d13ee9a6aafc8197ef1", + "destinationBlob": "d904082b351f1d9519d33d13ee9a6aafc8197ef1", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs", + "destination": "src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "de6547949c5b9b229d5520df360229f78b2ee09f", + "destinationBlob": "de6547949c5b9b229d5520df360229f78b2ee09f", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "src/modules/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs", + "destination": "src/extensions/secrets/Elsa.Secrets.Management/Services/DefaultSecretManager.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "d203e55ac42b8462fdb357bdb2945eb09a01f420", + "destinationBlob": "d203e55ac42b8462fdb357bdb2945eb09a01f420", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "dd11d79bfbcd77f54285b1668cc68b8fa7126c2b", + "destinationBlob": "dd11d79bfbcd77f54285b1668cc68b8fa7126c2b", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "2adfb29bd5ed7765a0c8412dac9f17830bc7fe44", + "destinationBlob": "2adfb29bd5ed7765a0c8412dac9f17830bc7fe44", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "9adf84ee4a46ad02be70797b5ebb6ecec623ab99", + "destinationBlob": "9adf84ee4a46ad02be70797b5ebb6ecec623ab99", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs", + "disposition": "imported-with-core-adaptation", + "reason": "Atomic method imported; Core API already provides TryDeleteAsync, so tests exercise interface dispatch and documentation drops obsolete external preview dependency caveat.", + "sourceBlob": "690a135e3aa4dae9817ffabcf4f11380371b4f5c", + "destinationBlob": "a880b56cd7e1de90c3ea8ccbcf11b874effc099e", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "ce457f43d82878d1876069b621bcedd972054fc0", + "destinationBlob": "ce457f43d82878d1876069b621bcedd972054fc0", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "62d305195034fd32032f661a04739192b9fd3128", + "destinationBlob": "62d305195034fd32032f661a04739192b9fd3128", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreOwnershipTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreOwnershipTests.cs", + "disposition": "imported-source", + "reason": "Shared SQLite/PostgreSQL ownership and race regressions imported unchanged.", + "sourceBlob": "478070461f11e96b0a3b7ff94106072f0adfe968", + "destinationBlob": "478070461f11e96b0a3b7ff94106072f0adfe968", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs", + "disposition": "imported-with-core-adaptation", + "reason": "Atomic method imported; Core API already provides TryDeleteAsync, so tests exercise interface dispatch and documentation drops obsolete external preview dependency caveat.", + "sourceBlob": "1ccda52a5f50af53fc6d31b9c1df0a5df0021622", + "destinationBlob": "65638efa4de47e6cdf8142d00a20f26fe65366ec", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs", + "disposition": "imported-with-core-adaptation", + "reason": "Atomic method imported; Core API already provides TryDeleteAsync, so tests exercise interface dispatch and documentation drops obsolete external preview dependency caveat.", + "sourceBlob": "5085ab94e9fdc2ae3e90fd513cd8ea7a7d06debd", + "destinationBlob": "9043ed52bd889a10db32f5a3545dfcf5467739da", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "d1f8e5f76bf92fdcc7200d0bbcb66794f393fcf0", + "destinationBlob": "d1f8e5f76bf92fdcc7200d0bbcb66794f393fcf0", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "118e5b9fb2500496c21672bd84d4de185afac68f", + "destinationBlob": "118e5b9fb2500496c21672bd84d4de185afac68f", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "e7d0b74392c37162db704e116e1a6ad99cd145ee", + "destinationBlob": "e7d0b74392c37162db704e116e1a6ad99cd145ee", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "75c5dc8a31070e3a45a2e18304aab26ecbc172d2", + "destinationBlob": "75c5dc8a31070e3a45a2e18304aab26ecbc172d2", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj", + "disposition": "imported-with-core-adaptation", + "reason": "Map newly required Elsa aggregate package to an in-repository Elsa project reference for migrated workflow/DI tests; preserve SSH.NET compatibility pin and existing Dapper project reference.", + "sourceBlob": "47666a7386af2263631c9e21f19b864838e00953", + "destinationBlob": "0ad8e97c62329c4777fdf1b256a3b3007615c984", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "d4470bdaf837cdf00b9a736c6ae25db4998eb725", + "destinationBlob": "d4470bdaf837cdf00b9a736c6ae25db4998eb725", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "9958d46b0ba1b1b0033c137b6dbfe9813a82bf1a", + "destinationBlob": "9958d46b0ba1b1b0033c137b6dbfe9813a82bf1a", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs", + "destination": "test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs", + "disposition": "imported-source", + "reason": "Imported reconciled source behavior/test.", + "sourceBlob": "3a8caa40bed987e4f0952be60c4e5613a1b6af65", + "destinationBlob": "3a8caa40bed987e4f0952be60c4e5613a1b6af65", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs", + "destination": "test/extensions/modules/secrets/Elsa.Secrets.Management.UnitTests/DefaultSecretManagerTests.cs", + "disposition": "already-identical", + "reason": "Core already contains the exact reconciled source blob.", + "sourceBlob": "eaa58b5e09b7dcc9a16017993583430954520e26", + "destinationBlob": "eaa58b5e09b7dcc9a16017993583430954520e26", + "sourceMode": "100644", + "destinationMode": "100644" + }, + { + "source": "test/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj", + "destination": "test/extensions/modules/secrets/Elsa.Secrets.Management.UnitTests/Elsa.Secrets.Management.UnitTests.csproj", + "disposition": "preserved-core-adaptation", + "reason": "Preserve in-repository project references and Core-only SSH.NET compatibility pin; Dapper test friend assemblies already present.", + "sourceBlob": "27c3fa9c74228beecde4c1686ec800744ba4530b", + "destinationBlob": "465fc3151c1ec55dbeb6385aeb9f9277c984e2fd", + "sourceMode": "100644", + "destinationMode": "100644" + } + ], + "acceptedSourceMain": "e189390b3ea10b730c4d66bf40e357f7621374ab" +} diff --git a/doc/integration-program/extensions-catchup.md b/doc/integration-program/extensions-catchup.md new file mode 100644 index 0000000000..b5fa6733ad --- /dev/null +++ b/doc/integration-program/extensions-catchup.md @@ -0,0 +1,43 @@ +# Extensions release reconciliation and Core catchup + +Tracker: Elsa Core #8623. The reconciled Extensions source preserves both original parents through a normal merge of release/3.9 into main. The subsequent cleanup removes one duplicate Dapper friend-assembly item introduced by the automatic project merge. + +Source main: `98a185a303b6c96347d25c780528ce37f2f864a3`. +Release/3.9: `89d4eb9b739ae135604aac2a1de18653a289bdb0`. +Reconciled source: `b76f8fd216fdc529d41fbf39fd712d3fcd4e80fd`. +Previous imported source: `3cf502955791cbd20375022ef4597f0e195faf8a`. +Core mapping base: `91a1c0723`. + +All stable 3.8 tags (3.8.0, 3.8.1, 3.8.2, 3.8.4), their release branch tips, and release/3.9 are ancestors of the reconciled source. No 3.8.3 tag exists in the inspected source repository. Main's package workflow and Directory.Build.props are unchanged, retaining base_version 3.10.0 and Elsa dependencies 3.10.0-preview.5760 / Studio 3.10.0-preview.1799. + +## Source conflict dispositions + +| Conflict path | Resolution | +| --- | --- | +| .github/workflows/packages.yml | Keep main publication settings and 3.10 base version. | +| Directory.Build.props | Keep main 3.10 preview dependency pins. | +| Dapper Modules/Runtime/Stores/KeyValueStore.cs | Keep main atomic delete method and preview-compatible documentation. | +| MongoDb Modules/Runtime/KeyValueStore.cs | Keep main scoped atomic delete method and preview-compatible documentation. | +| MongoDb Modules/Management/WorkflowInstanceStore.cs | Apply release scoped interruption through MongoDbStore.UpdateOneAsync while retaining finished-row refusal. | +| Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs | Combine release cutoff/filter cases with main stronger cross-tenant regression and parameterized tenant seed helper. | +| Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs | Keep main concrete-store calls and deterministic historical race baseline compatible with source preview dependency. | +| Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTests.cs | Keep main exact recursive Id-filter assertions. | +| Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs | Keep main concrete-store coverage against migration-built PostgreSQL. | +| Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs | Keep main concrete-store and SQLite race baseline coverage. | +| Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs | Include release BeforeLastUpdated coverage. | +| Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs | Include release exclusive less-than snapshots. | +| Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs | Include release quoted less-than assertions. | + +## Core mapping + +`extensions-catchup-dispositions.json` accounts for every changed source path using the established destination mapping in `scripts/integration-program/rehearse-import.py`. Root-level settings, workflows, solution metadata, and instructions are refreshed only as inert `.source` snapshots. Current Core build/publication configuration remains authoritative. + +The live slice adds the existence-guarded Dapper runtime migration 20008 (KeyValues and BookmarkQueueItems.SerializedOptions), atomic tenant-scoped TryDelete implementations, default-tenant NULL/empty compatibility, corrected prefix parameter binding, BeforeLastUpdated filtering, Mongo interruption/summary tenant scoping, DI-backed variable serialization, and JsonNode subtype/legacy compatibility. Imported regression coverage exercises migration-built SQLite/PostgreSQL and MongoDB containers. Core atomic-delete tests dispatch through IKeyValueStore because Core already supplies that API. + +Existing Core project-reference/SSH.NET compatibility adjustments and MigrationDatabases.QuotedIdentifiers/UnquotedIdentifiers are preserved. Identity V3_10 remains untouched. Files already equal to the reconciled source are recorded without rewriting them. + +Verification is recorded by the integrating lead after source and mapped Core test execution. No publication or cutover is performed by this local slice. + +## Persistence review follow-up + +[Extensions #286](https://github.com/elsa-workflows/elsa-extensions/pull/286) at `8626e81f4a780be7d5fde87a1ae8b15cc6915934` adds reviewed ownership-safe saves, literal prefix semantics, SQLite instant comparison, and legacy Mongo envelope-collision handling. The refreshed receipt includes all 48 paths from the previous imported source through this follow-up. Core keeps its documented API/layout adaptations. Full source and mapped persistence suite evidence is recorded in [the catch-up verification report](release-catchup-verification.md). diff --git a/doc/integration-program/legacy/extensions/.github/reviewers.md.source b/doc/integration-program/legacy/extensions/.github/reviewers.md.source new file mode 100644 index 0000000000..bcf3c31279 --- /dev/null +++ b/doc/integration-program/legacy/extensions/.github/reviewers.md.source @@ -0,0 +1,24 @@ +# Advisory PR reviewers + +Live list of automated reviewers for this repository (elsa-extensions). Agents read it before opening a PR. + +Last verified: 2026-10-04 + +| Reviewer | Status | How to request | Notes | +| --- | --- | --- | --- | +| GitHub Copilot code review (`copilot-pull-request-reviewer[bot]`) | live | Add reviewer `@copilot`: `gh pr edit --add-reviewer @copilot` (GitHub CLI 2.88 or later) | Advisory. A `@copilot review` comment does not trigger it. Bot-authored PRs need the org policy that lets Copilot review them. | +| Greptile (`greptile-apps[bot]`) | not live | Comment `@greptileai` (once enabled here) | Advisory; not part of the merge gate (see Rules). Installed, but does not review PRs here. | +| CodeRabbit (`coderabbitai[bot]`) | not live | Comment `@coderabbitai review` (once enabled here) | Not enabled for this repository. | +| Cursor Bugbot | not live | Top-level PR comment `cursor review` (once enabled) | Must first be enabled in the Cursor dashboard. `cursor[bot]` comments on PRs come from Cursor cloud agents, not Bugbot. | +| GitHub Code Quality (`github-code-quality[bot]`) | informational | Automatic; cannot be requested | CodeQL code-quality comments. Not an advisory pick and not part of the merge gate. | + +## Rules + +- Greptile does not review PRs on this repository. Do not wait for or request a Greptile score. +- Pick exactly one live advisory reviewer from the table (currently Copilot) and request it once the PR is open. Do not request a second reviewer. +- If the picked reviewer declines or skips the PR, request another live reviewer if there is one; otherwise continue without one. +- The PR author (human or agent) never reviews or approves its own PR. +- Merge gate: the only merge gate is an Elsa 3 Code Review on the PR whose body starts with the line `Elsa 3 Code Review: APPROVE + HIGH @ ` (the full 40-character SHA of the PR's current head commit), plus green CI on that head. The Code Review is posted as `sfmskywalker` with review event COMMENT, so its GitHub review state is COMMENTED, not APPROVED. A `REQUEST_CHANGES` verdict, a confidence below `HIGH`, or a SHA other than the current head does not pass. The Code Review is a separate reviewer from the PR author, even when both post from the same account. +- Pin the merge to the approved SHA: `gh pr merge --match-head-commit `, or the merge API (`PUT /repos/{owner}/{repo}/pulls/{n}/merge`) with `sha=`. Any push after the approval voids it; Code Review must re-confirm with `Elsa 3 Code Review: APPROVE + HIGH @ ` before merging. +- Greptile, CodeRabbit, Copilot and Bugbot are advisory only. Their findings feed into the Code Review, but none of them is required for merge: there is no Greptile score gate (no 5/5 requirement) and no waive process. +- Update this file whenever a reviewer is added, removed, or changes how it is requested. diff --git a/doc/integration-program/legacy/extensions/.github/workflows/packages.yml.source b/doc/integration-program/legacy/extensions/.github/workflows/packages.yml.source index a34ed96b06..4763f3a806 100644 --- a/doc/integration-program/legacy/extensions/.github/workflows/packages.yml.source +++ b/doc/integration-program/legacy/extensions/.github/workflows/packages.yml.source @@ -1,6 +1,16 @@ name: Packages on: workflow_dispatch: + inputs: + # Does nothing on main while base_version is 3.10.x. Dispatch VERSION is + # always ${base_version}-${prefix}.${run_number}, so the nuget.org job's + # !startsWith(..., '3.10.') gate never opens. Publishing 3.10 publicly + # later means deliberately removing that check and deciding how a dispatch + # run sets VERSION. + publish_nuget: + description: 'Publish packages to nuget.org (never for 3.10.x)' + type: boolean + default: false push: branches: - 'main' @@ -26,6 +36,8 @@ jobs: name: Build packages runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + version: ${{ steps.set_version.outputs.version }} steps: - name: Extract branch name run: | @@ -56,13 +68,17 @@ jobs: git branch --remote --contains | grep origin/${BRANCH_NAME} fi - name: Set VERSION variable + id: set_version run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then TAG_NAME=${{ github.ref }} # e.g., refs/tags/3.0.0 TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV + echo "version=${TAG_NAME}" >> $GITHUB_OUTPUT else - echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV + VERSION="${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" + echo "VERSION=${VERSION}" >> $GITHUB_ENV + echo "version=${VERSION}" >> $GITHUB_OUTPUT fi - uses: actions/setup-dotnet@v5 with: @@ -77,7 +93,7 @@ jobs: with: name: elsa-nuget-packages path: packages/*nupkg - if: ${{ github.event_name == 'release' || github.event_name == 'push'}} + if: ${{ github.event_name == 'release' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'}} publish_preview_feedz: name: Publish to feedz.io @@ -91,15 +107,30 @@ jobs: with: name: elsa-nuget-packages + - name: Check API key (feedz.io) + env: + API_KEY: ${{ secrets.FEEDZ_API_KEY }} + run: | + if [ -z "$API_KEY" ]; then + echo "::error::API key for feedz.io is empty. Check the FEEDZ_API_KEY repository secret; nothing was pushed." + exit 1 + fi + - name: Publish to feedz.io - run: dotnet nuget push *.nupkg -k ${{ secrets.FEEDZ_API_KEY }} -s ${{ env.feedz_feed_source }} --skip-duplicate + env: + API_KEY: ${{ secrets.FEEDZ_API_KEY }} + run: dotnet nuget push *.nupkg -k "$API_KEY" -s ${{ env.feedz_feed_source }} --skip-duplicate publish_nuget: name: Publish release to nuget.org needs: build runs-on: ubuntu-latest timeout-minutes: 20 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + # Dispatch-only. 3.10.x (including 3.10.0-preview.*) must never reach nuget.org. + # publish_nuget does nothing on main while base_version is 3.10.x. Publishing + # 3.10 publicly later means deliberately removing the 3.10. check and deciding + # how a dispatch run sets VERSION (always base_version-prefix.run_number). + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_nuget && !startsWith(needs.build.outputs.version, '3.10.') }} permissions: # Required to request the OIDC token that nuget.org exchanges for a short-lived API key (Trusted Publishing). id-token: write @@ -116,5 +147,16 @@ jobs: with: user: ${{ env.nuget_user }} + - name: Check API key (nuget.org) + env: + API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: | + if [ -z "$API_KEY" ]; then + echo "::error::API key for nuget.org is empty. Check the NuGet login (OIDC) step output and the Trusted Publishing policy on nuget.org; nothing was pushed." + exit 1 + fi + - name: Publish to nuget.org - run: dotnet nuget push *.nupkg -k ${{ steps.nuget_login.outputs.NUGET_API_KEY }} -s ${{ env.nuget_feed_source }} --skip-duplicate + env: + API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: dotnet nuget push *.nupkg -k "$API_KEY" -s ${{ env.nuget_feed_source }} --skip-duplicate diff --git a/doc/integration-program/legacy/extensions/AGENTS.md.source b/doc/integration-program/legacy/extensions/AGENTS.md.source new file mode 100644 index 0000000000..e12665606d --- /dev/null +++ b/doc/integration-program/legacy/extensions/AGENTS.md.source @@ -0,0 +1,3 @@ +# AGENTS.md + +Before opening a PR, read `.github/reviewers.md` and request exactly one live advisory reviewer from it once the PR is open. Greptile does not review PRs here, and all automated reviewers are advisory; merge only on the gate described there (Elsa 3 Code Review `APPROVE + HIGH @ ` plus green CI, merge pinned to that SHA). diff --git a/doc/integration-program/legacy/extensions/Directory.Build.props.source b/doc/integration-program/legacy/extensions/Directory.Build.props.source index 1626bfabc7..1150b573e0 100644 --- a/doc/integration-program/legacy/extensions/Directory.Build.props.source +++ b/doc/integration-program/legacy/extensions/Directory.Build.props.source @@ -37,8 +37,8 @@ - 3.10.0-preview.5705 - 3.10.0-preview.1749 + 3.10.0-preview.5760 + 3.10.0-preview.1799 diff --git a/doc/integration-program/legacy/extensions/Directory.Packages.props.source b/doc/integration-program/legacy/extensions/Directory.Packages.props.source index 3d241154a7..ae4204b424 100644 --- a/doc/integration-program/legacy/extensions/Directory.Packages.props.source +++ b/doc/integration-program/legacy/extensions/Directory.Packages.props.source @@ -165,6 +165,9 @@ + + + diff --git a/doc/integration-program/legacy/extensions/Elsa.Extensions.sln.source b/doc/integration-program/legacy/extensions/Elsa.Extensions.sln.source index 56758f86ca..42aace26df 100644 --- a/doc/integration-program/legacy/extensions/Elsa.Extensions.sln.source +++ b/doc/integration-program/legacy/extensions/Elsa.Extensions.sln.source @@ -336,6 +336,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "runtimes", "runtimes", "{74 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Runtime.ProtoActor.UnitTests", "test\modules\runtimes\Elsa.Workflows.Runtime.ProtoActor.UnitTests\Elsa.Workflows.Runtime.ProtoActor.UnitTests.csproj", "{209765CA-207B-44F0-94E4-0711235E7F99}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "secrets", "secrets", "{A1526B50-A5D1-398B-3070-5440A8E5BDFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Management.UnitTests", "test\modules\secrets\Elsa.Secrets.Management.UnitTests\Elsa.Secrets.Management.UnitTests.csproj", "{02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1610,6 +1614,18 @@ Global {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x64.Build.0 = Release|Any CPU {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x86.ActiveCfg = Release|Any CPU {C115D266-ED50-4584-B059-B4BB48B54C77}.Release|x86.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x64.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x64.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x86.ActiveCfg = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Debug|x86.Build.0 = Debug|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|Any CPU.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x64.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x64.Build.0 = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x86.ActiveCfg = Release|Any CPU + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1758,6 +1774,8 @@ Global {799D5262-2BB1-C4D2-CB3D-522B0B7186CF} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} {1994376D-9775-4709-88E5-BE44618546AD} = {799D5262-2BB1-C4D2-CB3D-522B0B7186CF} {C115D266-ED50-4584-B059-B4BB48B54C77} = {273F8587-516E-41F3-93B9-D2F8FDC188A4} + {A1526B50-A5D1-398B-3070-5440A8E5BDFE} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} + {02CB40E6-1DC0-4AC8-B9F1-D268CA26654F} = {A1526B50-A5D1-398B-3070-5440A8E5BDFE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {11A771DA-B728-445E-8A88-AE1C84C3B3A6} diff --git a/doc/integration-program/legacy/studio/.github/reviewers.md.source b/doc/integration-program/legacy/studio/.github/reviewers.md.source new file mode 100644 index 0000000000..896cc7fc6f --- /dev/null +++ b/doc/integration-program/legacy/studio/.github/reviewers.md.source @@ -0,0 +1,23 @@ +# Advisory PR reviewers + +Live list of automated reviewers for this repository (elsa-studio). Agents read it before opening a PR. + +Last verified: 2026-10-04 + +| Reviewer | Status | How to request | Notes | +| --- | --- | --- | --- | +| Greptile (`greptile-apps[bot]`) | live | Automatic when a PR is opened; comment `@greptileai` to review a new head after a push | Advisory; not part of the merge gate (see Rules). Posts a "Confidence Score: N/5" summary and a "Greptile Review" check. Reviews only allow-listed authors; others get "PR author is not in the allowed authors list". | +| CodeRabbit (`coderabbitai[bot]`) | live | Automatic on PRs into `main`; comment `@coderabbitai review` for other base branches or a re-review after a push | Advisory. Skips PRs into other base branches unless requested. Reviews draw on an hourly allowance, so do not request it while its automatic review is pending. | +| GitHub Copilot code review (`copilot-pull-request-reviewer[bot]`) | live | Add reviewer `@copilot`: `gh pr edit --add-reviewer @copilot` (GitHub CLI 2.88 or later) | Advisory. A `@copilot review` comment does not trigger it. Bot-authored PRs need the org policy that lets Copilot review them. | +| Cursor Bugbot | not live | Top-level PR comment `cursor review` (once enabled) | Must first be enabled in the Cursor dashboard. `cursor[bot]` comments on PRs come from Cursor cloud agents, not Bugbot. | + +## Rules + +- Greptile reviews automatically when a PR is opened. It is advisory: after a push you may comment `@greptileai` for a fresh review of the new head, but nobody waits for a Greptile score. +- Besides Greptile, request at most one additional advisory reviewer (CodeRabbit or Copilot) once the PR is open. If your pick reviews automatically on this PR (CodeRabbit does on PRs into `main`), wait for that run instead of requesting it; request it manually only if no run appears, the PR targets another base branch, or you need a re-review after a push. +- If the additional reviewer declines or skips the PR, you may request the other one instead. +- The PR author (human or agent) never reviews or approves its own PR. +- Merge gate: the only merge gate is an Elsa 3 Code Review on the PR whose body starts with the line `Elsa 3 Code Review: APPROVE + HIGH @ ` (the full 40-character SHA of the PR's current head commit), plus green CI on that head. The Code Review is posted as `sfmskywalker` with review event COMMENT, so its GitHub review state is COMMENTED, not APPROVED. A `REQUEST_CHANGES` verdict, a confidence below `HIGH`, or a SHA other than the current head does not pass. The Code Review is a separate reviewer from the PR author, even when both post from the same account. +- Pin the merge to the approved SHA: `gh pr merge --match-head-commit `, or the merge API (`PUT /repos/{owner}/{repo}/pulls/{n}/merge`) with `sha=`. Any push after the approval voids it; Code Review must re-confirm with `Elsa 3 Code Review: APPROVE + HIGH @ ` before merging. +- Greptile, CodeRabbit, Copilot and Bugbot are advisory only. Their findings feed into the Code Review, but none of them is required for merge: there is no Greptile score gate (no 5/5 requirement) and no waive process. +- Update this file whenever a reviewer is added, removed, or changes how it is requested. diff --git a/doc/integration-program/legacy/studio/.github/workflows/packages.yml.source b/doc/integration-program/legacy/studio/.github/workflows/packages.yml.source index 021633e6d6..169022ee58 100644 --- a/doc/integration-program/legacy/studio/.github/workflows/packages.yml.source +++ b/doc/integration-program/legacy/studio/.github/workflows/packages.yml.source @@ -1,6 +1,15 @@ name: Packages on: workflow_dispatch: + inputs: + publish_nuget: + description: Publish packages to nuget.org + type: boolean + default: false + publish_npm: + description: Publish packages to npmjs.org + type: boolean + default: false push: branches: - 'main' @@ -30,6 +39,8 @@ jobs: name: Build packages runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Extract branch name run: | @@ -68,6 +79,9 @@ jobs: else echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV fi + - name: Export version + id: version + run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - uses: actions/setup-dotnet@v4 with: dotnet-version: | @@ -227,7 +241,8 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + # Dispatch-only. 3.10.x (including 3.10.0-preview.*) must never reach npmjs.org. + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_npm && !startsWith(needs.build.outputs.version, '3.10.') }} permissions: contents: read id-token: write # Required: lets this job request the OIDC token npmjs.com exchanges for publish rights. @@ -308,15 +323,27 @@ jobs: name: elsa-studio-nuget-packages path: elsa-studio-nuget-packages + - name: Check API key (feedz.io) + env: + API_KEY: ${{ secrets.FEEDZ_API_KEY }} + run: | + if [ -z "$API_KEY" ]; then + echo "::error::API key for feedz.io is empty. Check the FEEDZ_API_KEY repository secret; nothing was pushed." + exit 1 + fi + - name: Publish to feedz.io - run: dotnet nuget push ./elsa-studio-nuget-packages/*.nupkg -k ${{ secrets.FEEDZ_API_KEY }} -s ${{ env.nuget_feed_feedzio }} --skip-duplicate + env: + API_KEY: ${{ secrets.FEEDZ_API_KEY }} + run: dotnet nuget push ./elsa-studio-nuget-packages/*.nupkg -k "$API_KEY" -s ${{ env.nuget_feed_feedzio }} --skip-duplicate publish_nuget_nuget: name: Publish to nuget.org needs: build runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + # Dispatch-only. 3.10.x (including 3.10.0-preview.*) must never reach nuget.org. + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_nuget && !startsWith(needs.build.outputs.version, '3.10.') }} permissions: contents: read id-token: write # Required: lets this job request the OIDC token that nuget.org exchanges for a temporary API key. @@ -337,5 +364,16 @@ jobs: with: user: ${{ secrets.NUGET_USER }} # nuget.org username (profile name), not an email address. + - name: Check API key (nuget.org) + env: + API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: | + if [ -z "$API_KEY" ]; then + echo "::error::API key for nuget.org is empty. Check the NuGet login (OIDC) step output and the Trusted Publishing policy on nuget.org; nothing was pushed." + exit 1 + fi + - name: Publish to nuget.org - run: dotnet nuget push ./elsa-studio-nuget-packages/*.nupkg --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} --source ${{ env.nuget_feed_nuget }} --skip-duplicate + env: + API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: dotnet nuget push ./elsa-studio-nuget-packages/*.nupkg --api-key "$API_KEY" --source ${{ env.nuget_feed_nuget }} --skip-duplicate diff --git a/doc/integration-program/legacy/studio/AGENTS.md.source b/doc/integration-program/legacy/studio/AGENTS.md.source index ef274f298a..154e8a421d 100644 --- a/doc/integration-program/legacy/studio/AGENTS.md.source +++ b/doc/integration-program/legacy/studio/AGENTS.md.source @@ -2,3 +2,5 @@ Keep repository-wide guidance feature-neutral. Feature-specific requirements, plans, and tasks belong under their feature directory in `specs/`. + +Before opening a PR, read `.github/reviewers.md`: Greptile is advisory (it reviews automatically; nobody waits for its score), request at most one additional advisory reviewer from the list once the PR is open, and merge only on the gate described there (Elsa 3 Code Review `APPROVE + HIGH @ ` plus green CI, merge pinned to that SHA). diff --git a/doc/integration-program/legacy/studio/Directory.Packages.props.source b/doc/integration-program/legacy/studio/Directory.Packages.props.source index 36f1fed756..af5cdc6030 100644 --- a/doc/integration-program/legacy/studio/Directory.Packages.props.source +++ b/doc/integration-program/legacy/studio/Directory.Packages.props.source @@ -11,7 +11,7 @@ 0.2.0 - + diff --git a/doc/integration-program/legacy/studio/Elsa.Studio.sln.source b/doc/integration-program/legacy/studio/Elsa.Studio.sln.source index d4bc19a67c..521903b637 100644 --- a/doc/integration-program/legacy/studio/Elsa.Studio.sln.source +++ b/doc/integration-program/legacy/studio/Elsa.Studio.sln.source @@ -204,6 +204,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Security.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Environments.Tests", "src\modules\Elsa.Studio.Environments.Tests\Elsa.Studio.Environments.Tests.csproj", "{88C83814-4390-445B-B2FD-EDBEDBEF1874}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Localization.Tests", "src\modules\Elsa.Studio.Localization.Tests\Elsa.Studio.Localization.Tests.csproj", "{E43411B4-86DD-422C-99D2-62410123C715}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1055,6 +1057,18 @@ Global {88C83814-4390-445B-B2FD-EDBEDBEF1874}.Release|x64.Build.0 = Release|Any CPU {88C83814-4390-445B-B2FD-EDBEDBEF1874}.Release|x86.ActiveCfg = Release|Any CPU {88C83814-4390-445B-B2FD-EDBEDBEF1874}.Release|x86.Build.0 = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|x64.ActiveCfg = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|x64.Build.0 = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|x86.ActiveCfg = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Debug|x86.Build.0 = Debug|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|Any CPU.Build.0 = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|x64.ActiveCfg = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|x64.Build.0 = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|x86.ActiveCfg = Release|Any CPU + {E43411B4-86DD-422C-99D2-62410123C715}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1147,6 +1161,7 @@ Global {C2AEA205-ACE7-45A1-993C-0E180BE6CD9B} = {D66B9A40-8608-46F3-9868-625C50EACE43} {700DD3B9-4019-41DE-9C9E-285E4F54DA30} = {D66B9A40-8608-46F3-9868-625C50EACE43} {88C83814-4390-445B-B2FD-EDBEDBEF1874} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {E43411B4-86DD-422C-99D2-62410123C715} = {D66B9A40-8608-46F3-9868-625C50EACE43} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5B8719CC-CF87-45E1-BE1A-13842F951B28} diff --git a/doc/integration-program/release-catchup-refs.json b/doc/integration-program/release-catchup-refs.json new file mode 100644 index 0000000000..20292e5790 --- /dev/null +++ b/doc/integration-program/release-catchup-refs.json @@ -0,0 +1,216 @@ +{ + "checkedAt": "2026-10-06", + "candidateCommit": "c304c05f45f263587cefd9e7dca6fb7a6a06ba58", + "refs": [ + { + "repository": "elsa-core", + "ref": "refs/heads/release/3.8.1", + "commit": "249d0f077f8a0b5e6cb1a42b02b49f2f636b5ab5", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/heads/release/3.8.2", + "commit": "33181ae3048f628f591a0155b5665a8e4d1bcea2", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/heads/release/3.8.4", + "commit": "33181ae3048f628f591a0155b5665a8e4d1bcea2", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/heads/release/3.9.0", + "commit": "5d3582b6309a2fd8ea33ebdb8c39fb040bfdc514", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.0", + "commit": "8191ae30554ea001b38bb44902dd90dc98c7a106", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.0-preview1", + "commit": "87ee19f5b3f116b419468855819065e2b6d87135", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.0-rc1", + "commit": "f6c35cf1eb5558348a61352fc4eba5925731118d", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.0-rc2", + "commit": "dff7d9f987394c3c2ba8003e6f9c803e97194fbc", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.1", + "commit": "33181ae3048f628f591a0155b5665a8e4d1bcea2", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.2", + "commit": "33181ae3048f628f591a0155b5665a8e4d1bcea2", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.8.4", + "commit": "33181ae3048f628f591a0155b5665a8e4d1bcea2", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-core", + "ref": "refs/tags/3.9.0", + "commit": "6436609a1a3874d3fea7ccf690897792f5a1f702", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/heads/release/3.8.1", + "commit": "99de962e49f7793011196667659ac0cdb8df3fb7", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/heads/release/3.8.2", + "commit": "1c72dc02c837919059b60efe5df2ed57ff2db2d9", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/heads/release/3.8.4", + "commit": "9bff3f785fd13bd80a3a7ecf88fec4aec8eef7ae", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/heads/release/3.9.0", + "commit": "a30ed7c997dfb3cff1d5d095a4ee19ff03c7fe42", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.0", + "commit": "85395246140c6b192a2457992da8704b0cdec3d0", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.0-preview1", + "commit": "8145e7e3de208abbf33eaa80de7f106cf0ed137a", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.0-rc1", + "commit": "daaa232a758814d070774139c90dd48298baf670", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.0-rc2", + "commit": "61ebe170f478a356a92dc66ef5258616f542acef", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.1", + "commit": "99de962e49f7793011196667659ac0cdb8df3fb7", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.2", + "commit": "1c72dc02c837919059b60efe5df2ed57ff2db2d9", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.3", + "commit": "f6ad44a50a303695d65fcc6a1fccac5ba6d0f48d", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.8.4", + "commit": "9bff3f785fd13bd80a3a7ecf88fec4aec8eef7ae", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-studio", + "ref": "refs/tags/3.9.0", + "commit": "a30ed7c997dfb3cff1d5d095a4ee19ff03c7fe42", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/heads/release/3.8.1", + "commit": "0619be93b67dbfd933e148663eeffb657e52f69c", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/heads/release/3.8.2", + "commit": "e7d05ef930dfde2aa85bf9dcaf841e5a6ede0a1e", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/heads/release/3.8.4", + "commit": "154ba15fb4da85b4bebecfbe43639579cbda1d0d", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/heads/release/3.9.0", + "commit": "89d4eb9b739ae135604aac2a1de18653a289bdb0", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.8.0", + "commit": "66861ae0828e2c15459e8d9981f5ce1080bd3269", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.8.0-rc2", + "commit": "a44e2b09af1202ff4936f493756e114c357eff81", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.8.1", + "commit": "0619be93b67dbfd933e148663eeffb657e52f69c", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.8.2", + "commit": "e7d05ef930dfde2aa85bf9dcaf841e5a6ede0a1e", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.8.4", + "commit": "154ba15fb4da85b4bebecfbe43639579cbda1d0d", + "ancestorOfCoreCandidate": true + }, + { + "repository": "elsa-extensions", + "ref": "refs/tags/3.9.0", + "commit": "89d4eb9b739ae135604aac2a1de18653a289bdb0", + "ancestorOfCoreCandidate": true + } + ] +} diff --git a/doc/integration-program/release-catchup-verification.md b/doc/integration-program/release-catchup-verification.md new file mode 100644 index 0000000000..099266b99d --- /dev/null +++ b/doc/integration-program/release-catchup-verification.md @@ -0,0 +1,48 @@ +# Release and upstream consolidation catch-up + +This checkpoint implements [Core #8623](https://github.com/elsa-workflows/elsa-core/issues/8623) under [program #8194](https://github.com/elsa-workflows/elsa-core/issues/8194). The original import is already merged through #8409. This catch-up carries subsequent release/source changes into the established Core layout; publisher cutover and the rest of the program remain separate work. + +## History and source accounting + +- Core's missing release ancestry is reconciled with reviewed equivalent changes as recorded in [the Core report](../reports/core-release-reconciliation-2026-10-06.md). +- [Extensions #285](https://github.com/elsa-workflows/elsa-extensions/pull/285) reconciles source release history at `b76f8fd216fdc529d41fbf39fd712d3fcd4e80fd`. Its accepted main merge is `70ea3018da0075c5590d4f537b7aaa74a5965527`; both are preserved in Core history. The persistence review follow-up [Extensions #286](https://github.com/elsa-workflows/elsa-extensions/pull/286) at `8626e81f4a780be7d5fde87a1ae8b15cc6915934` was accepted as main merge `f237b64cfa368a119f653d44e555bda90581a5cf`; both are preserved in Core history. Every one of the 48 changed source paths is accounted in [the Extensions receipt](extensions-catchup-dispositions.json). +- [Studio #1061](https://github.com/elsa-workflows/elsa-studio/pull/1061) reconciles source release history at `2cdfcc525945e99a2ab2c1a02fa3053a0e8dce20`. Its accepted main merge is `e712b5e6df735ed132a1bf20ea77f35a5a1b5759`; both are preserved in Core history. The follow-up [Studio #1125](https://github.com/elsa-workflows/elsa-studio/pull/1125) at `734da587eb3aca7b935375cce60bd536b6afdc6f` was accepted as main merge `3d028a4e9a3267b291f3d43d8064b5c81a6f2759`; this history is also preserved. Every one of the 353 changed source paths is accounted in [the Studio receipt](studio-catchup-dispositions.json). +- [The release ref audit](release-catchup-refs.json) records all 35 advertised matching release branches and tags across the three repositories, including matching prerelease tags. All are ancestors of Core candidate `c304c05f45f263587cefd9e7dca6fb7a6a06ba58`. Remote identities are used because imported repositories can share tag names. + +Receipts distinguish imported fixes from source-identical files, newer Core implementations, mapped project references, and inert historical configuration. Live Core build/version/publication settings remain authoritative. No repository cutover, feed publication, deployment, or archival is included. + +## Pinned validation checkpoints + +| Scope | Checked commit | Result | +| --- | --- | --- | +| Core release reconciliation: identity diagnostic, tenant visibility, and tenant-scoped memory key/value tests | `04bc3414692977cf3b918077ccd511da18938061` source tree | 50 passed, 0 failed, 0 skipped | +| Mapped Extensions: Persistence.Dapper, Dapper, MongoDB, Secrets | `14c6bd6512eb707d29072877e9f17582e6b6769b` | 202 passed, 0 failed, 0 skipped, .NET 10 | +| Combined Core Studio: Core, Dashboard, Environments, ExternalAuthentication, Administration, Workflows | `c304c05f45f263587cefd9e7dca6fb7a6a06ba58` | 1,339 passed, 0 failed, 0 skipped, .NET 10 | +| Extensions source hosted Compile/Test/Pack | `b76f8fd216fdc529d41fbf39fd712d3fcd4e80fd` | [Run 37381631473](https://github.com/elsa-workflows/elsa-extensions/actions/runs/37381631473): 498 passed, 2 unrelated provider tests skipped | +| Studio source hosted build and full solution tests | `dea56950ab7e269d13d008fd61b2ebd10454843c` | [Run 37382436307](https://github.com/elsa-workflows/elsa-studio/actions/runs/37382436307): 1,902 passed, 0 failed, 0 skipped | + +The subsequent Studio review found a logout form that ignored a hosting prefix. Studio source `2cdfcc525945e99a2ab2c1a02fa3053a0e8dce20` reuses the existing Core implementation; its rendered regression failed for two subpaths before the fix and all 16 focused sign-out cases passed after it. Core production already handled the prefix. Stronger Core rendered tests passed all 17 focused sign-out cases at `88c241591fdb61766bc5e3ce5b6dadb082aec67d`. The refreshed full source CI at `2cdfcc525945e99a2ab2c1a02fa3053a0e8dce20` subsequently passed with 1,908 tests, zero skipped and zero build errors ([run 37384476983](https://github.com/elsa-workflows/elsa-studio/actions/runs/37384476983)). Independent exact-head approval and Greptile 5/5 preceded the source merge. These later results are separate from the earlier full-suite checkpoints above. + +## Review follow-ups + +The final persistence slice preserves existing tenant ownership during Dapper saves, treats prefix metacharacters literally (including PostgreSQL backslashes), and compares SQLite date/time offsets as instants. A primary-key-protected insert and narrow duplicate retry prevent reassignment during concurrent creation; updates change only Value within the ambient tenant. The ownership regression produced three failures and one control pass before the fix, then all 26 SQLite/PostgreSQL cases passed. SQLite sub-millisecond timestamp ties remain excluded from the exclusive liveness cutoff until a later scan. + +At Extensions source `8626e81f4a780be7d5fde87a1ae8b15cc6915934`, the complete persistence Dapper suite passed 98 tests and generic Dapper passed 20. At mapped Core `e6dd8a1617d3f54f683af9c6ff810aa9d3bf213b`, the respective suites passed 130 and 21. All had zero failures/skips. Independent review parsed the raw TRX results. Final Extensions hosted Compile/Test/Pack passed with 537 tests and the same two unrelated provider skips, zero failures and zero build errors ([run 37387491859](https://github.com/elsa-workflows/elsa-extensions/actions/runs/37387491859)). Independent exact-head approval and green CI preceded the source merge. + +Mongo regression coverage now distinguishes legacy JsonObject documents with envelope-like scalar/array fields from genuine envelopes, including polymorphic dispatch. The added scalar and array regressions failed before their fixes. Final converter tests passed 21/21 on both trees; the complete unchanged Mongo slice passed 72 source and 89 Core tests, zero failures/skips. The public VariableSerializer constructor change is documented in [the migration note](../migrations/mongodb-variable-serializer.md). + +Studio follow-ups prevent foreign feature names from taking the Elsa short-name fallback and cap the 600-pixel drawer to the viewport. Source `734da587eb3aca7b935375cce60bd536b6afdc6f` passed the complete hosted build and 1,911 tests with zero failures/skips across 18 target-framework results ([run 37386031336](https://github.com/elsa-workflows/elsa-studio/actions/runs/37386031336)). Independent exact-head approval and Greptile 5/5 preceded merge. Final Core CI separately qualifies these mapped changes. + +The mapped Extensions suites used real `postgres:16` and `mongo:7.0.24` containers; container startup errors are not silently accepted. The six combined Studio suite totals are 155, 108, 23, 490, 73, and 490 respectively. Legacy asset validation also passed for all 163 assets (113 represented, 50 retired). Independent review checked receipt path completeness and Git modes/blobs, the preserved source/release ancestry, compatibility decisions, and the final functional aggregate. `git diff --check` passed. + +These are scoped evidence checkpoints, not a claim that final hosted Core CI passed. Current-head CI and final independent review are recorded on the integration PR linked from #8623. The previously accepted Studio environment-load retry follow-up remains [Studio #1070](https://github.com/elsa-workflows/elsa-studio/issues/1070). + +## Bounded save retry follow-up + +Final review identified an availability edge case under repeated same-tenant delete/reinsert churn. [Extensions #287](https://github.com/elsa-workflows/elsa-extensions/pull/287) at `a24de2986e0a02c335bdd3f86e47bd049949d3b4` and mapped Core `6e7f73942dc2b820470661e9d5b016bf8dc8ee4a` cap owned-update retries at five attempts, then throw a descriptive concurrency exception. They preserve scoped Value-only writes and never report an unapplied value as saved. Cancellation retains priority. + +A deterministic real SQLite/PostgreSQL delete/reinsert regression failed on both providers before the fix, bounded by a distinct test safety exception without cancellation or background operations. Afterward, all 28 ownership cases passed; the complete source persistence suite passed 100/100 and Core passed 132/132, with zero failures/skips. Independent review verified the implementation and raw TRX. The source receipt still accounts for all 48 paths, now pinned through this follow-up. Final hosted results are recorded on the corresponding PRs. + +Extensions #287 merged as `e189390b3ea10b730c4d66bf40e357f7621374ab`, whose tree is identical to the approved source commit; its accepted history is preserved in Core. Final source hosted Compile/Test/Pack passed 539 tests with zero failures and the same two unrelated provider skips ([run 37390345354](https://github.com/elsa-workflows/elsa-extensions/actions/runs/37390345354)). Independent exact-head approval and green CI preceded merge. + +The preceding full Core candidate `8d4df05ae90bb5e73bfe996c5f958cb0e4c4c63d` passed all CI: [Compile/Test](https://github.com/elsa-workflows/elsa-core/actions/runs/37388150840) passed 7,614 tests with 145 known skips, including mapped Studio and all persistence suites; [coverage and package construction](https://github.com/elsa-workflows/elsa-core/actions/runs/37388144208) also passed. Independent audit compared the skips with the earlier successful candidate: the same 144 UserTasks provider-conformance cases and one clustered-workflow case. This remains a preceding checkpoint; current-head checks on PR #8624 qualify the final bounded-retry change. diff --git a/doc/integration-program/studio-catchup-dispositions.json b/doc/integration-program/studio-catchup-dispositions.json new file mode 100644 index 0000000000..eb7b53a916 --- /dev/null +++ b/doc/integration-program/studio-catchup-dispositions.json @@ -0,0 +1,7557 @@ +{ + "previousSource": "24337549a28be797b639708a87d5fcb9b8c693ee", + "reconciledSource": "734da587eb3aca7b935375cce60bd536b6afdc6f", + "coreBase": "91a1c07232e806436c5e759086bbb9b0aee92234", + "coreMappedDelta": "3bd9c4d0b8e904c538d9429201e2e9aaf8173c28", + "coreHistoryJoin": "4f2835f8eb9aa3304b90cbc084a91b107dd8283a", + "sourceDeltaPathCount": 353, + "allPathsDispositioned": true, + "unresolvedPathCount": 0, + "counts": { + "applied-source-capability-fixture": 1, + "applied-upstream": 14, + "applied-upstream-deletion": 1, + "equivalent-formatting-or-comments": 18, + "equivalent-imports-or-empty-class-syntax": 54, + "equivalent-line-endings": 1, + "equivalent-razor-import-order": 4, + "inert-source-snapshot": 5, + "merged-backend-create-capability": 1, + "merged-project-references": 1, + "merged-source-hosted-form-regressions": 1, + "merged-upstream-with-core-compatibility": 1, + "merged-upstream-with-core-layout": 1, + "retained-core-improvement": 36, + "retained-core-project-layout": 10, + "superseded-by-core-permission-snapshot": 2, + "upstream-identical": 202 + }, + "liveConfiguration": [ + { + "path": ".github/workflows/packages.yml", + "before": [ + "100644", + "cb7c4c4776910af8a45753779b9db92fb3bf3f62" + ], + "after": [ + "100644", + "cb7c4c4776910af8a45753779b9db92fb3bf3f62" + ], + "unchanged": true + }, + { + "path": ".github/workflows/pr.yml", + "before": [ + "100644", + "de75809f085dc701b8f5887318e6851d733300be" + ], + "after": [ + "100644", + "de75809f085dc701b8f5887318e6851d733300be" + ], + "unchanged": true + }, + { + "path": ".github/reviewers.md", + "before": [ + "100644", + "f4d8f3ecc80ecb7703980d3cb3dea13c17ec07c6" + ], + "after": [ + "100644", + "f4d8f3ecc80ecb7703980d3cb3dea13c17ec07c6" + ], + "unchanged": true + }, + { + "path": "Directory.Build.props", + "before": [ + "100644", + "969fcc3f5c104d1be78231c0ac23ed3ab3737736" + ], + "after": [ + "100644", + "969fcc3f5c104d1be78231c0ac23ed3ab3737736" + ], + "unchanged": true + }, + { + "path": "Directory.Build.targets", + "before": [ + "100644", + "2a61364c98e134dfbbf6ba8c5d6498f0bffcb81b" + ], + "after": [ + "100644", + "2a61364c98e134dfbbf6ba8c5d6498f0bffcb81b" + ], + "unchanged": true + }, + { + "path": "Directory.Packages.props", + "before": [ + "100644", + "239b33106fe0d6e2c49ff2368629c30642e61137" + ], + "after": [ + "100644", + "239b33106fe0d6e2c49ff2368629c30642e61137" + ], + "unchanged": true + }, + { + "path": "Elsa.sln", + "before": [ + "100644", + "b7ac1e68d8d60163bdbb9d66c7cfb5fa0d302c27" + ], + "after": [ + "100644", + "b7ac1e68d8d60163bdbb9d66c7cfb5fa0d302c27" + ], + "unchanged": true + }, + { + "path": "src/studio/Directory.Build.props", + "before": [ + "100644", + "7e69b18f079fd9fe76d57ec3f4f0c5bfd158bd78" + ], + "after": [ + "100644", + "7e69b18f079fd9fe76d57ec3f4f0c5bfd158bd78" + ], + "unchanged": true + }, + { + "path": "src/studio/Directory.Build.targets", + "before": [ + "100644", + "5f9e77a4587403c2af4e2864b7dc89cdcf04a3da" + ], + "after": [ + "100644", + "5f9e77a4587403c2af4e2864b7dc89cdcf04a3da" + ], + "unchanged": true + } + ], + "coreValidation": "Source mapping and ancestry are verified at the recorded history joins. Existing Core OIDC production code and 17 focused rendered sign-out cases were verified at 88c241591fdb61766bc5e3ce5b6dadb082aec67d. Namespace and viewport follow-ups passed source PR1125 full CI; final Core qualification is recorded on PR8624. Detailed checkpoints are in release-catchup-verification.md.", + "mapping": [ + { + "source": ".github/reviewers.md", + "destination": "doc/integration-program/legacy/studio/.github/reviewers.md.source", + "beforeSource": null, + "afterSource": [ + "100644", + "896cc7fc6f23bf5c1b8773f5033eb5c9d77253e6" + ], + "core": null, + "disposition": "inert-source-snapshot", + "note": "Preserve the exact updated standalone source as an inert .source snapshot; consolidated live configuration remains reviewed Core configuration.", + "result": [ + "100644", + "896cc7fc6f23bf5c1b8773f5033eb5c9d77253e6" + ] + }, + { + "source": ".github/workflows/packages.yml", + "destination": "doc/integration-program/legacy/studio/.github/workflows/packages.yml.source", + "beforeSource": [ + "100644", + "021633e6d6e2a88914fd8401a1e8598af64af5ff" + ], + "afterSource": [ + "100644", + "169022ee5831fad2b61714807ef4498342b36074" + ], + "core": [ + "100644", + "021633e6d6e2a88914fd8401a1e8598af64af5ff" + ], + "disposition": "inert-source-snapshot", + "note": "Preserve the exact updated standalone source as an inert .source snapshot; consolidated live configuration remains reviewed Core configuration.", + "result": [ + "100644", + "169022ee5831fad2b61714807ef4498342b36074" + ] + }, + { + "source": "AGENTS.md", + "destination": "doc/integration-program/legacy/studio/AGENTS.md.source", + "beforeSource": [ + "100644", + "ef274f298a82b717a922a8c507b391efd03d499b" + ], + "afterSource": [ + "100644", + "154e8a421d9e12ccf5a96ffd2c1abdf809b03eb0" + ], + "core": [ + "100644", + "ef274f298a82b717a922a8c507b391efd03d499b" + ], + "disposition": "inert-source-snapshot", + "note": "Preserve the exact updated standalone source as an inert .source snapshot; consolidated live configuration remains reviewed Core configuration.", + "result": [ + "100644", + "154e8a421d9e12ccf5a96ffd2c1abdf809b03eb0" + ] + }, + { + "source": "Directory.Packages.props", + "destination": "doc/integration-program/legacy/studio/Directory.Packages.props.source", + "beforeSource": [ + "100644", + "36f1fed75630477567417831e712feb791c38acb" + ], + "afterSource": [ + "100644", + "af5cdc6030c99cca120e027936bfd44d5f6d93b1" + ], + "core": [ + "100644", + "36f1fed75630477567417831e712feb791c38acb" + ], + "disposition": "inert-source-snapshot", + "note": "Preserve the exact updated standalone source as an inert .source snapshot; consolidated live configuration remains reviewed Core configuration.", + "result": [ + "100644", + "af5cdc6030c99cca120e027936bfd44d5f6d93b1" + ] + }, + { + "source": "Elsa.Studio.sln", + "destination": "doc/integration-program/legacy/studio/Elsa.Studio.sln.source", + "beforeSource": [ + "100644", + "d4bc19a67c8e88ab772dd8f4c4a002712f3f0b56" + ], + "afterSource": [ + "100644", + "521903b6370a6b9e585892b0c3f3f062cb7953f1" + ], + "core": [ + "100644", + "d4bc19a67c8e88ab772dd8f4c4a002712f3f0b56" + ], + "disposition": "inert-source-snapshot", + "note": "Preserve the exact updated standalone source as an inert .source snapshot; consolidated live configuration remains reviewed Core configuration.", + "result": [ + "100644", + "521903b6370a6b9e585892b0c3f3f062cb7953f1" + ] + }, + { + "source": "doc/AUTHENTICATION_ARCHITECTURE.md", + "destination": "doc/studio/AUTHENTICATION_ARCHITECTURE.md", + "beforeSource": [ + "100644", + "18af5025f8d967ddc1d1bdb4cd53a62d44b460c8" + ], + "afterSource": [ + "100644", + "acda9393bd9914093ce171da16be5ea0724df118" + ], + "core": [ + "100644", + "acda9393bd9914093ce171da16be5ea0724df118" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "acda9393bd9914093ce171da16be5ea0724df118" + ] + }, + { + "source": "doc/DASHBOARD_ARCHITECTURE_BLUEPRINT.md", + "destination": "doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md", + "beforeSource": [ + "100644", + "e1f6cdc3f4e777ff14f4b2f3aec08a4a0be02f71" + ], + "afterSource": [ + "100644", + "3c209fd80fab827f4a182d2a62091fd716a2aec6" + ], + "core": [ + "100644", + "1ded84743a8a15b3897f9c9daf64b0e2d269a846" + ], + "disposition": "retained-core-improvement", + "note": "Retain Core runtime-only overview loading for users with runtime view permission and no widget.", + "result": [ + "100644", + "1ded84743a8a15b3897f9c9daf64b0e2d269a846" + ] + }, + { + "source": "specs/007-operational-dashboard/prd.md", + "destination": "specs/studio/007-operational-dashboard/prd.md", + "beforeSource": [ + "100644", + "b846bdf16809d22f1449867bb5946d4eb7cd5d5a" + ], + "afterSource": [ + "100644", + "55f57dda8239b299bf36b01673e16f276fe5f0f7" + ], + "core": [ + "100644", + "37126f664320f247eb84407042014dbf0bac878a" + ], + "disposition": "retained-core-improvement", + "note": "Retain Core runtime-only overview behavior already implemented and tested.", + "result": [ + "100644", + "37126f664320f247eb84407042014dbf0bac878a" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/ClaimsPermissionServiceTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "e1a92a1b094fdcd2de2657c979969633497f9a04" + ], + "core": null, + "disposition": "superseded-by-core-permission-snapshot", + "note": "Core deliberately uses backend IdentityPermissionService and permission refresh/cache snapshots, with regression coverage in src/studio/modules/Elsa.Studio.Security.Tests/IdentityPermissionServiceTests.cs. Retain canonical adapter instead of adding unregistered claims-only fallback.", + "result": null + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "16ef7ff13722aadd2386e606eaff18356f7f2cf1" + ], + "core": [ + "100644", + "16ef7ff13722aadd2386e606eaff18356f7f2cf1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "16ef7ff13722aadd2386e606eaff18356f7f2cf1" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "dddb6c66333211c9dfcb1e748dd8bc1c1ab531cf" + ], + "core": [ + "100644", + "987feff3249175528992bff1cfe7cd6e1ee6b937" + ], + "disposition": "retained-core-improvement", + "note": "Retain additional permission snapshot invalidation regression test; upstream authentication-change test remains.", + "result": [ + "100644", + "987feff3249175528992bff1cfe7cd6e1ee6b937" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "c22e79a2a5e01d3ab4918682deddfcc6935dd8d8" + ], + "core": [ + "100644", + "c22e79a2a5e01d3ab4918682deddfcc6935dd8d8" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c22e79a2a5e01d3ab4918682deddfcc6935dd8d8" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "15d70783b011e0b8b850e677e938457cd0c457cb" + ], + "core": [ + "100644", + "15d70783b011e0b8b850e677e938457cd0c457cb" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "15d70783b011e0b8b850e677e938457cd0c457cb" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "8268b4204570f68bb01c4c5070497b0c1834b548" + ], + "core": [ + "100644", + "173fe4fcfa3234bd8d12321d588179224c80df5d" + ], + "disposition": "retained-core-improvement", + "note": "Retain additional unresolved-permission regression and compilable empty-class syntax.", + "result": [ + "100644", + "173fe4fcfa3234bd8d12321d588179224c80df5d" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "0a5b8a6ebc0df4716e5820c0cca93c0d88c019ff" + ], + "core": [ + "100644", + "0a5b8a6ebc0df4716e5820c0cca93c0d88c019ff" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0a5b8a6ebc0df4716e5820c0cca93c0d88c019ff" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/PermissionViewTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionViewTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "3514a2dc8cdec24ed9d188a77247f9ed02a2f3a3" + ], + "core": [ + "100644", + "5a905d8fb51620f3cf767f730a126bfd1181cdc7" + ], + "disposition": "retained-core-improvement", + "note": "Retain additional unresolved-permission regression coverage.", + "result": [ + "100644", + "5a905d8fb51620f3cf767f730a126bfd1181cdc7" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Authorization/UserPermissionsTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Authorization/UserPermissionsTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "bc6338a34c41f285ff7ea74c40217c709b80d99a" + ], + "core": [ + "100644", + "bc6338a34c41f285ff7ea74c40217c709b80d99a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "bc6338a34c41f285ff7ea74c40217c709b80d99a" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "1faffbb71e2369dd15182fd0dcae309025a82752" + ], + "core": [ + "100644", + "5350ef93b7b81729a471883d16298a93e1cf12f7" + ], + "disposition": "merged-upstream-with-core-compatibility", + "note": "Import source namespace-collision regressions while preserving the additional Core compatibility tests.", + "result": [ + "100644", + "10e68a25a0bbeec85edd75253f8a323911e1a22e" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj", + "beforeSource": [ + "100644", + "33e2e2a5d6d23a2b0e89063ff16eb74e13068db7" + ], + "afterSource": [ + "100644", + "e3018d9a7f3bdc1de9cb9993b76fd8a5986fbca7" + ], + "core": [ + "100644", + "f9681e4481165fbfa5313b82ddb09712b536b43c" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "f9681e4481165fbfa5313b82ddb09712b536b43c" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Errors/AuthorizationFailureExtensionsTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Errors/AuthorizationFailureExtensionsTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "cad4566b52ae50f54e9f7649a4cb4f38fca4075a" + ], + "core": [ + "100644", + "cad4566b52ae50f54e9f7649a4cb4f38fca4075a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cad4566b52ae50f54e9f7649a4cb4f38fca4075a" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Errors/ErrorTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Errors/ErrorTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "90636cb071cc6530a3396a8feca4e54da8474a4a" + ], + "core": [ + "100644", + "90636cb071cc6530a3396a8feca4e54da8474a4a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "90636cb071cc6530a3396a8feca4e54da8474a4a" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Errors/MainLayoutErrorBoundaryTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Errors/MainLayoutErrorBoundaryTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "ade7062363974e07e91666794d3d250a8864591f" + ], + "core": [ + "100644", + "d85b5e7f2ab6313f70d423d16d81f031d652dba9" + ], + "disposition": "retained-core-improvement", + "note": "Retain initialized test feature stub to avoid unrelated dashboard waits.", + "result": [ + "100644", + "d85b5e7f2ab6313f70d423d16d81f031d652dba9" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Errors/MarkerUnauthorizedProvider.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Errors/MarkerUnauthorizedProvider.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "3553afaa398e6023da5a5478af1f51321af634d1" + ], + "core": [ + "100644", + "3553afaa398e6023da5a5478af1f51321af634d1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3553afaa398e6023da5a5478af1f51321af634d1" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/Errors/StubTranslations.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/Errors/StubTranslations.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "34a58c1a7c234b0d10e92b32aeb162b910862626" + ], + "core": [ + "100644", + "34a58c1a7c234b0d10e92b32aeb162b910862626" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "34a58c1a7c234b0d10e92b32aeb162b910862626" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs", + "beforeSource": [ + "100644", + "2096c1a6ac80586e2809d302faf4755e0e85affc" + ], + "afterSource": [ + "100644", + "f9d80ba544f6fb63ae36a505621ab3d5a86fbc69" + ], + "core": [ + "100644", + "f9d80ba544f6fb63ae36a505621ab3d5a86fbc69" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "f9d80ba544f6fb63ae36a505621ab3d5a86fbc69" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs", + "destination": "src/studio/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs", + "beforeSource": [ + "100644", + "ff2ed7639f7dfae90336878134260c778a528362" + ], + "afterSource": [ + "100644", + "35eb6d21c09a99a5650f32b23293e3585b62e4b3" + ], + "core": [ + "100644", + "35eb6d21c09a99a5650f32b23293e3585b62e4b3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "35eb6d21c09a99a5650f32b23293e3585b62e4b3" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/ClaimsPermissionService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "812526fc766f008f5587d9f51069a944218ad6f6" + ], + "core": null, + "disposition": "superseded-by-core-permission-snapshot", + "note": "Core deliberately uses backend IdentityPermissionService and permission refresh/cache snapshots, with regression coverage in src/studio/modules/Elsa.Studio.Security.Tests/IdentityPermissionServiceTests.cs. Retain canonical adapter instead of adding unregistered claims-only fallback.", + "result": null + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "b93ab5bf9a84ab44af2f23efb92b16461ff8f5e2" + ], + "core": [ + "100644", + "8227babfc196a16c1688ed67b6bfca4055f4f541" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "8227babfc196a16c1688ed67b6bfca4055f4f541" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/Permission.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/Permission.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "59b26af55cb77197d5d23118eae37616d2bbd7cd" + ], + "core": [ + "100644", + "59b26af55cb77197d5d23118eae37616d2bbd7cd" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "59b26af55cb77197d5d23118eae37616d2bbd7cd" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "cc448f5789a4fc805816b6baaaf30fcdce64db28" + ], + "core": [ + "100644", + "cc448f5789a4fc805816b6baaaf30fcdce64db28" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cc448f5789a4fc805816b6baaaf30fcdce64db28" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "bf17c91315b3836221e81a95bb99d2bcffa26730" + ], + "core": [ + "100644", + "bf17c91315b3836221e81a95bb99d2bcffa26730" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "bf17c91315b3836221e81a95bb99d2bcffa26730" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "1662f66ca40b8dec45f00f4e6e8eeecff26c9bca" + ], + "core": [ + "100644", + "1662f66ca40b8dec45f00f4e6e8eeecff26c9bca" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "1662f66ca40b8dec45f00f4e6e8eeecff26c9bca" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "cbc4a509df3ac43d719d7ab396c6ba5d4e1a7667" + ], + "core": [ + "100644", + "cbc4a509df3ac43d719d7ab396c6ba5d4e1a7667" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cbc4a509df3ac43d719d7ab396c6ba5d4e1a7667" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs", + "beforeSource": [ + "100644", + "a068d373d4dde7f58aff35947343ad6d93163fc2" + ], + "afterSource": [ + "100644", + "8cc7941727dfc5026e8b423deba20c403a96ca37" + ], + "core": [ + "100644", + "6ff2ff7d1a344abc9467f16cfd67eb72bbe05a9f" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "6ff2ff7d1a344abc9467f16cfd67eb72bbe05a9f" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Extensions/AuthorizationFailureExtensions.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Extensions/AuthorizationFailureExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "41c925ab0c60192f0547ca9d25bd5d4e3e4cdc4f" + ], + "core": [ + "100644", + "41c925ab0c60192f0547ca9d25bd5d4e3e4cdc4f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "41c925ab0c60192f0547ca9d25bd5d4e3e4cdc4f" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "e30008c1ecb739cb246c89591992a1baf7cb5ddd" + ], + "afterSource": [ + "100644", + "610b050fc66000966df6f28f59758e3d148eb005" + ], + "core": [ + "100644", + "735d8d39c0f364558cac9b5605ba22b8a78880b8" + ], + "disposition": "retained-core-improvement", + "note": "Retain backend permission adapter and refresh signal; do not re-register claims-only fallback.", + "result": [ + "100644", + "735d8d39c0f364558cac9b5605ba22b8a78880b8" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Models/MenuItem.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Models/MenuItem.cs", + "beforeSource": [ + "100644", + "6b8bff2481e00fcaf68479656c804240b7280a8f" + ], + "afterSource": [ + "100644", + "a1784996188e239c06c28f27feef8e2c9a84d49d" + ], + "core": [ + "100644", + "a1784996188e239c06c28f27feef8e2c9a84d49d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a1784996188e239c06c28f27feef8e2c9a84d49d" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Models/MenuNavigationGroup.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Models/MenuNavigationGroup.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4a9f83baba761b627efaa37d8c4f3f5aec599fc1" + ], + "core": [ + "100644", + "4a9f83baba761b627efaa37d8c4f3f5aec599fc1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4a9f83baba761b627efaa37d8c4f3f5aec599fc1" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Models/MenuPage.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Models/MenuPage.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4993e805bf04e1d5c21cb8dcc6ce0ac2f3e2a82c" + ], + "core": [ + "100644", + "4993e805bf04e1d5c21cb8dcc6ce0ac2f3e2a82c" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4993e805bf04e1d5c21cb8dcc6ce0ac2f3e2a82c" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs", + "beforeSource": [ + "100644", + "64d5481fdb5277b830449a3d30189e13d18f7e54" + ], + "afterSource": [ + "100644", + "9f7970392268594efbde37b9d4688b56be5f64dd" + ], + "core": [ + "100644", + "2b79c42be3f293deb12fd0647f05ae372a4bcdce" + ], + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "9f7970392268594efbde37b9d4688b56be5f64dd" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs", + "beforeSource": [ + "100644", + "873392791cf13c1cb3ce81d456e7b27be74861fd" + ], + "afterSource": [ + "100644", + "6a9d4075d031003d194959b4fa19b62db7ff0a5b" + ], + "core": [ + "100644", + "f214caa239784c10870e8f148dfabd6a269f47a1" + ], + "disposition": "retained-core-improvement", + "note": "Same upstream permission filtering with Core fail-closed adapter explanation and required braces.", + "result": [ + "100644", + "f214caa239784c10870e8f148dfabd6a269f47a1" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "b66c1b3769e5a529c857ed8f89085c59c1509173" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Use exact foreign-name matching; restrict static/shell short-name fallback to Elsa-qualified requests and catalog entries.", + "result": [ + "100644", + "b66c1b3769e5a529c857ed8f89085c59c1509173" + ] + }, + { + "source": "src/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs", + "destination": "src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs", + "beforeSource": [ + "100644", + "b84fae142654802f89431b8a4620f19787735c01" + ], + "afterSource": [ + "100644", + "1543f90167bdd758fd02e1019b9249faea2238a6" + ], + "core": [ + "100644", + "b84fae142654802f89431b8a4620f19787735c01" + ], + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "1543f90167bdd758fd02e1019b9249faea2238a6" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/AccessDenied.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/AccessDenied.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "d2295ea11d3113d359c949c86d32508e0855fea4" + ], + "core": [ + "100644", + "d2295ea11d3113d359c949c86d32508e0855fea4" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d2295ea11d3113d359c949c86d32508e0855fea4" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/AccessNotice.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/AccessNotice.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "336b1d849f3c2f8dc73c5cfe2327e79dbd76abe2" + ], + "core": [ + "100644", + "336b1d849f3c2f8dc73c5cfe2327e79dbd76abe2" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "336b1d849f3c2f8dc73c5cfe2327e79dbd76abe2" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "cad0828607f0cd3c45030a08b4a9a5d70176f6aa" + ], + "core": [ + "100644", + "a2c686efe3c124effc041917413ad7a3dae73ae6" + ], + "disposition": "retained-core-improvement", + "note": "Retain early auth subscription and guarded asynchronous notification handling.", + "result": [ + "100644", + "a2c686efe3c124effc041917413ad7a3dae73ae6" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "49f809010e3f125898dc2bd2bb490e4f4a1dd331" + ], + "core": [ + "100644", + "49f809010e3f125898dc2bd2bb490e4f4a1dd331" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "49f809010e3f125898dc2bd2bb490e4f4a1dd331" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/BusyState.cs", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/BusyState.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "e51b4bf30827ea795242e62776344f1868a12cf5" + ], + "core": [ + "100644", + "e51b4bf30827ea795242e62776344f1868a12cf5" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e51b4bf30827ea795242e62776344f1868a12cf5" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/Error.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/Error.razor", + "beforeSource": [ + "100644", + "6bd4124b6a6228c29bd4ffdd9fce50a0556a75f1" + ], + "afterSource": [ + "100644", + "249e1df4799bbe4ede06609ea77a599b78068fd6" + ], + "core": [ + "100644", + "60cf47ad84385ba4631e014c7a643ee34b74ce85" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "60cf47ad84385ba4631e014c7a643ee34b74ce85" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/Error.razor.cs", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/Error.razor.cs", + "beforeSource": [ + "100644", + "336062308f5cafddd3df1d6e87803eb50af2b643" + ], + "afterSource": [ + "100644", + "36337404f15400da21a82ad80d3f7b904cd6d8e3" + ], + "core": [ + "100644", + "36337404f15400da21a82ad80d3f7b904cd6d8e3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "36337404f15400da21a82ad80d3f7b904cd6d8e3" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/NavMenu.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/NavMenu.razor", + "beforeSource": [ + "100644", + "980d55821c7311dca44ab54c3a0a311b4e4dad38" + ], + "afterSource": [ + "100644", + "9a2b9854c878157d75275d290db64b5bd84dfee7" + ], + "core": [ + "100644", + "9cbf79e839ab0f4b9a44d476f98771a318db1ab0" + ], + "disposition": "retained-core-improvement", + "note": "Retain permission snapshot cache invalidation and safe notification handlers; upstream auth fallback remains.", + "result": [ + "100644", + "9cbf79e839ab0f4b9a44d476f98771a318db1ab0" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/NoAccessiblePages.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/NoAccessiblePages.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "7b530327dc38b0511471bd759d209a45e9507c50" + ], + "core": [ + "100644", + "7b530327dc38b0511471bd759d209a45e9507c50" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "7b530327dc38b0511471bd759d209a45e9507c50" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/PermissionPageGuard.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/PermissionPageGuard.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "67e8901a7745affb2a921b707f6231b1a53d9444" + ], + "core": [ + "100644", + "67e8901a7745affb2a921b707f6231b1a53d9444" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "67e8901a7745affb2a921b707f6231b1a53d9444" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/PermissionTrackingComponentBase.cs", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/PermissionTrackingComponentBase.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "f896138493a5c511b7b4b63c49b71113777cfa1d" + ], + "core": [ + "100644", + "29701aec444b50b151db919def687dd2545e13b2" + ], + "disposition": "retained-core-improvement", + "note": "Retain shared permission snapshot invalidation and safe notification handlers.", + "result": [ + "100644", + "29701aec444b50b151db919def687dd2545e13b2" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Components/PermissionView.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Components/PermissionView.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "6748271df3151c7d0694fae36dad4f29cccb3ab6" + ], + "core": [ + "100644", + "6748271df3151c7d0694fae36dad4f29cccb3ab6" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "6748271df3151c7d0694fae36dad4f29cccb3ab6" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Extensions/KeyboardEventArgsExtensions.cs", + "destination": "src/studio/framework/Elsa.Studio.Shared/Extensions/KeyboardEventArgsExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "d82823bdd60ba20873493554864e239d9a1626ff" + ], + "core": [ + "100644", + "d82823bdd60ba20873493554864e239d9a1626ff" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d82823bdd60ba20873493554864e239d9a1626ff" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Extensions/MenuServiceExtensions.cs", + "destination": "src/studio/framework/Elsa.Studio.Shared/Extensions/MenuServiceExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "e4b443228901fa5bbfa9de47f0fcde9915047826" + ], + "core": [ + "100644", + "e4b443228901fa5bbfa9de47f0fcde9915047826" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e4b443228901fa5bbfa9de47f0fcde9915047826" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shared/Layouts/MainLayout.razor", + "destination": "src/studio/framework/Elsa.Studio.Shared/Layouts/MainLayout.razor", + "beforeSource": [ + "100644", + "0984ef92f3e61f110edc4088d36070fc27f759c9" + ], + "afterSource": [ + "100644", + "03f413151bb4956fc662693ae17afb00f2eb6c80" + ], + "core": [ + "100644", + "ea631a06bfe32683c9707a4caef5f5562013b254" + ], + "disposition": "equivalent-line-endings", + "note": "Same content after CRLF normalization; retain Core line endings.", + "result": [ + "100644", + "ea631a06bfe32683c9707a4caef5f5562013b254" + ] + }, + { + "source": "src/framework/Elsa.Studio.Shell/App.razor", + "destination": "src/studio/framework/Elsa.Studio.Shell/App.razor", + "beforeSource": [ + "100644", + "cc7a2575f9fb18eda29bf9adef434811cf016422" + ], + "afterSource": [ + "100644", + "002da89789505cfc197b9dac7aad0048448b76c3" + ], + "core": [ + "100644", + "719144cf6aa14b82a341a68abc11ccd71b4a5290" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "719144cf6aa14b82a341a68abc11ccd71b4a5290" + ] + }, + { + "source": "src/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml", + "destination": "src/studio/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml", + "beforeSource": [ + "100644", + "df2469277d3dcd632d2cc3b932d225f311ac9949" + ], + "afterSource": [ + "100644", + "d805cbf51282f6d4b0dc02f9993d06141290c5f5" + ], + "core": [ + "100644", + "d805cbf51282f6d4b0dc02f9993d06141290c5f5" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d805cbf51282f6d4b0dc02f9993d06141290c5f5" + ] + }, + { + "source": "src/modules/Elsa.Studio.AI/AIPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.AI/AIPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4b795c59094ab60b5222e8dec72825e32f37238d" + ], + "core": [ + "100644", + "4b795c59094ab60b5222e8dec72825e32f37238d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4b795c59094ab60b5222e8dec72825e32f37238d" + ] + }, + { + "source": "src/modules/Elsa.Studio.AI/Menu/WeaverMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.AI/Menu/WeaverMenu.cs", + "beforeSource": [ + "100644", + "b7a5f9419cc6271713aaf5b591219e849f9d122f" + ], + "afterSource": [ + "100644", + "20a26c961fe1713efee4112651ab3f3859718ee9" + ], + "core": [ + "100644", + "20a26c961fe1713efee4112651ab3f3859718ee9" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "20a26c961fe1713efee4112651ab3f3859718ee9" + ] + }, + { + "source": "src/modules/Elsa.Studio.AI/UI/Pages/Weaver.razor", + "destination": "src/studio/modules/Elsa.Studio.AI/UI/Pages/Weaver.razor", + "beforeSource": [ + "100644", + "b06595d151de9ad2056d2963a740c18892066a95" + ], + "afterSource": [ + "100644", + "dd4feda26fa16955d628cebb3c7bd1985aa0995f" + ], + "core": [ + "100644", + "dd4feda26fa16955d628cebb3c7bd1985aa0995f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "dd4feda26fa16955d628cebb3c7bd1985aa0995f" + ] + }, + { + "source": "src/modules/Elsa.Studio.AI/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.AI/_Imports.razor", + "beforeSource": [ + "100644", + "69f56ebfb11492dc7ef102d69ae8f95756a8849d" + ], + "afterSource": [ + "100644", + "944ef3592bacc03ae0e31e7b3c33cf86e02d1288" + ], + "core": [ + "100644", + "e09aa96531fba97854ecff93d57faa09e3e4c7fd" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "e09aa96531fba97854ecff93d57faa09e3e4c7fd" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/AdministrationNavigationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/AdministrationNavigationTests.cs", + "beforeSource": [ + "100644", + "8fb89e0fce9df0f0255a939e9b8fc077982c22a0" + ], + "afterSource": [ + "100644", + "1ab6d4281af1f0430e566b2d14e7f8f1ad1adfdb" + ], + "core": [ + "100644", + "40edf7154a467cc78a5e0d5a73fc385a07f7b21e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "40edf7154a467cc78a5e0d5a73fc385a07f7b21e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/Elsa.Studio.Administration.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/Elsa.Studio.Administration.Tests.csproj", + "beforeSource": [ + "100644", + "d757c938575c059c9640d2deb514c733548652b9" + ], + "afterSource": [ + "100644", + "f36b48c2442383df89c99a241cf95024090e4714" + ], + "core": [ + "100644", + "0a80852562e08c0c5a9165ac2c6a02089e2419d3" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "0a80852562e08c0c5a9165ac2c6a02089e2419d3" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/ForbiddenResponseTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/ForbiddenResponseTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "bec51a3e2b5dc860dc7d18e9da4751a714727db5" + ], + "core": [ + "100644", + "ba6bee5527c77776d9a0fc093d6232d829364f41" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "ba6bee5527c77776d9a0fc093d6232d829364f41" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/ForbiddingBackend.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/ForbiddingBackend.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "8aecee3cbb18e3a647d90aad715a41bde8bf9fed" + ], + "core": [ + "100644", + "8aecee3cbb18e3a647d90aad715a41bde8bf9fed" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8aecee3cbb18e3a647d90aad715a41bde8bf9fed" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/IdentityManagementTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/IdentityManagementTests.cs", + "beforeSource": [ + "100644", + "0b9f42307f049f903e7079d1dedae7cc6c23d188" + ], + "afterSource": [ + "100644", + "f317541b3b346a0e764aef4025a0937e177f3ac6" + ], + "core": [ + "100644", + "f317541b3b346a0e764aef4025a0937e177f3ac6" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "f317541b3b346a0e764aef4025a0937e177f3ac6" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/LabelsMenuTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/LabelsMenuTests.cs", + "beforeSource": [ + "100644", + "0dffec66992f7178601fa330d58fe0bbf552073b" + ], + "afterSource": [ + "100644", + "ab4c5afdfd4059ace3d7bfe902825eb4e6089f79" + ], + "core": [ + "100644", + "9a7fb25916b517bb822c76259ec5201afe979f2b" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "9a7fb25916b517bb822c76259ec5201afe979f2b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4d49c46cebbf6e3809dcc7e11a1d59ee12b7e3ec" + ], + "core": [ + "100644", + "ded133c1f6fcd9371a32e99bc1f8502a5dbf9721" + ], + "disposition": "applied-source-capability-fixture", + "note": "Use authoritative backend inline-create capability; fixture default models current write policy, explicit capability override models varying backend policy without changing the API.", + "result": [ + "100644", + "4d49c46cebbf6e3809dcc7e11a1d59ee12b7e3ec" + ] + }, + { + "source": "src/modules/Elsa.Studio.Administration.Tests/RefusedWriteTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Administration.Tests/RefusedWriteTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "14b81c76e2e881837011d38d0cf95bcd020b3263" + ], + "core": [ + "100644", + "14b81c76e2e881837011d38d0cf95bcd020b3263" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "14b81c76e2e881837011d38d0cf95bcd020b3263" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/AlterationPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Alterations/AlterationPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "636b8735b1591193b382a6a2e754606e87481d30" + ], + "core": [ + "100644", + "636b8735b1591193b382a6a2e754606e87481d30" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "636b8735b1591193b382a6a2e754606e87481d30" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Components/AlterationDesignerHost.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Components/AlterationDesignerHost.razor.cs", + "beforeSource": [ + "100644", + "6d70a9639bd5e2c6aab3bd8a7d057b6f08414b6d" + ], + "afterSource": [ + "100644", + "a39492130537bc69b2137c105f18e91bf8c46b0a" + ], + "core": [ + "100644", + "a39492130537bc69b2137c105f18e91bf8c46b0a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a39492130537bc69b2137c105f18e91bf8c46b0a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Components/AlterationSubmitDialog.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Components/AlterationSubmitDialog.razor", + "beforeSource": [ + "100644", + "bea764dc334a2eb129d6c517fb5aee0e34649ed2" + ], + "afterSource": [ + "100644", + "4bfafb1496f37c6aea6c3a9ffba3b73ab4df9a6a" + ], + "core": [ + "100644", + "4bfafb1496f37c6aea6c3a9ffba3b73ab4df9a6a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4bfafb1496f37c6aea6c3a9ffba3b73ab4df9a6a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Menu/AlterationsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Menu/AlterationsMenu.cs", + "beforeSource": [ + "100644", + "279f47fc02d83235aa93960eeaed17e373952133" + ], + "afterSource": [ + "100644", + "69889193cc7f2a573af8954ff9661fd9bec48de0" + ], + "core": [ + "100644", + "69889193cc7f2a573af8954ff9661fd9bec48de0" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "69889193cc7f2a573af8954ff9661fd9bec48de0" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Pages/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Pages/Index.razor", + "beforeSource": [ + "100644", + "f1fe4d42f19e6b639c05a0b5a7499da18c8c0c61" + ], + "afterSource": [ + "100644", + "3e10c72d29e824ffaab655b832e18e0da383d9d7" + ], + "core": [ + "100644", + "3e10c72d29e824ffaab655b832e18e0da383d9d7" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3e10c72d29e824ffaab655b832e18e0da383d9d7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Pages/Instances/Edit.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Pages/Instances/Edit.razor", + "beforeSource": [ + "100644", + "c1ff222e1edc5886d5e0ce71e9be95fa4d7122ed" + ], + "afterSource": [ + "100644", + "99f3c9c6c6140f4c543994614ed10e896a9eeedb" + ], + "core": [ + "100644", + "99f3c9c6c6140f4c543994614ed10e896a9eeedb" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "99f3c9c6c6140f4c543994614ed10e896a9eeedb" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Pages/Instances/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Pages/Instances/Index.razor", + "beforeSource": [ + "100644", + "08562ac6d2bbeaf545721ed4270edd37c4ca184f" + ], + "afterSource": [ + "100644", + "b585775deca2abaa8b24619be16751e665afcf64" + ], + "core": [ + "100644", + "b585775deca2abaa8b24619be16751e665afcf64" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b585775deca2abaa8b24619be16751e665afcf64" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/Pages/Plans/Details.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/Pages/Plans/Details.razor", + "beforeSource": [ + "100644", + "3ed516e987e3cc012afcbfb0dfcce6e98db2701e" + ], + "afterSource": [ + "100644", + "528b69c5e237729e3cd672aa607a15d289382858" + ], + "core": [ + "100644", + "528b69c5e237729e3cd672aa607a15d289382858" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "528b69c5e237729e3cd672aa607a15d289382858" + ] + }, + { + "source": "src/modules/Elsa.Studio.Alterations/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Alterations/_Imports.razor", + "beforeSource": [ + "100644", + "4d0017fbf30e84b181683c307eaf807f1f2225d0" + ], + "afterSource": [ + "100644", + "336120e03c2c8348a80b4922133803ba51b6c813" + ], + "core": [ + "100644", + "88150b2e102fab08998a1ee3aad2ad07acd8e56e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "88150b2e102fab08998a1ee3aad2ad07acd8e56e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "84fa7e93cf7c21e4a4f0b40e4af8d9932d5bbdb8" + ], + "afterSource": [ + "100644", + "fa5bf97ec2b4712e2a42f54f4ddee326be826bd1" + ], + "core": [ + "100644", + "fa5bf97ec2b4712e2a42f54f4ddee326be826bd1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "fa5bf97ec2b4712e2a42f54f4ddee326be826bd1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.Abstractions/LocalReturnPath.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.Abstractions/LocalReturnPath.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "3bb344457495c3818b58b5287ff12342e99fe098" + ], + "core": [ + "100644", + "3bb344457495c3818b58b5287ff12342e99fe098" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3bb344457495c3818b58b5287ff12342e99fe098" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Components/ElsaIdentityLoginMethod.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Components/ElsaIdentityLoginMethod.razor", + "beforeSource": [ + "100644", + "050647c8dc8ef9bf0e6f3819dc9cb8f661f3a7f3" + ], + "afterSource": [ + "100644", + "9d14b276602dfed3e60e59e43b34075cc92fd48f" + ], + "core": [ + "100644", + "b2f90b68f794a8ad01922a19b7f321bceb61c6e7" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "b2f90b68f794a8ad01922a19b7f321bceb61c6e7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Components/ElsaIdentityUserMenu.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Components/ElsaIdentityUserMenu.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "9d76ed419e8d1b3ca7dc2c19318eb160b56846f9" + ], + "core": [ + "100644", + "9d76ed419e8d1b3ca7dc2c19318eb160b56846f9" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9d76ed419e8d1b3ca7dc2c19318eb160b56846f9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/ElsaIdentityUIFeature.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/ElsaIdentityUIFeature.cs", + "beforeSource": [ + "100644", + "e69c2d334c1ac375af385580a9a285cf9ac85255" + ], + "afterSource": [ + "100644", + "178edb49cb5601a86b3ab23a4de629b7d9bad154" + ], + "core": [ + "100644", + "178edb49cb5601a86b3ab23a4de629b7d9bad154" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "178edb49cb5601a86b3ab23a4de629b7d9bad154" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "7d34d2194dfefe1a625d9621f98b473cce4a2a8f" + ], + "afterSource": [ + "100644", + "dfb1626600492a45d3f5b7cdc8622870dba524a9" + ], + "core": [ + "100644", + "26d10899b5d4588169b73e8119cbb76d96ff0a66" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "26d10899b5d4588169b73e8119cbb76d96ff0a66" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/README.md", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity.UI/README.md", + "beforeSource": [ + "100644", + "e8b2973334550bc13b59468eef61bc561a866af9" + ], + "afterSource": [ + "100644", + "e5a3c609bce09019550299cc40a1db0f9e696d69" + ], + "core": [ + "100644", + "e5a3c609bce09019550299cc40a1db0f9e696d69" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e5a3c609bce09019550299cc40a1db0f9e696d69" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Contracts/ISignOutService.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Contracts/ISignOutService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "b88872a7ed4b6a88e8ce5b534118458104fcebfa" + ], + "core": [ + "100644", + "b88872a7ed4b6a88e8ce5b534118458104fcebfa" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b88872a7ed4b6a88e8ce5b534118458104fcebfa" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Extensions/JwtAccessorExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Extensions/JwtAccessorExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "6f1af5ea7626620a8044e6cfabfdef81a045a806" + ], + "core": [ + "100644", + "6f1af5ea7626620a8044e6cfabfdef81a045a806" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "6f1af5ea7626620a8044e6cfabfdef81a045a806" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "3c4bbb875e8e5b0979695fc9236aae18f85dc0bf" + ], + "afterSource": [ + "100644", + "0f8e9c250707e6abc5ee05257ac2c24cad5e2677" + ], + "core": [ + "100644", + "0f8e9c250707e6abc5ee05257ac2c24cad5e2677" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0f8e9c250707e6abc5ee05257ac2c24cad5e2677" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Models/ElsaIdentityLogoutJsonContext.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Models/ElsaIdentityLogoutJsonContext.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "27dc4c4798648d7084c4ebb5b62fd1180bff78cf" + ], + "core": [ + "100644", + "27dc4c4798648d7084c4ebb5b62fd1180bff78cf" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "27dc4c4798648d7084c4ebb5b62fd1180bff78cf" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/README.md", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/README.md", + "beforeSource": [ + "100644", + "9e45327d8373a38f500d7a405163b69df41b2cac" + ], + "afterSource": [ + "100644", + "9b5c1ced53d5e39f7ab8981ab8ffef2c56d7b57c" + ], + "core": [ + "100644", + "fb0d3d47aa200737d0e3980505b7bcf82d2c2873" + ], + "disposition": "retained-core-improvement", + "note": "Retain additional shared token key and session-gate explanation.", + "result": [ + "100644", + "fb0d3d47aa200737d0e3980505b7bcf82d2c2873" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentityRefreshTokenService.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentityRefreshTokenService.cs", + "beforeSource": [ + "100644", + "a51b461f19f8d7fbf355fbb20c122d905400d27f" + ], + "afterSource": [ + "100644", + "2a5df7adc5462996bc9da6eb4c31c4d4d3305a02" + ], + "core": [ + "100644", + "2a5df7adc5462996bc9da6eb4c31c4d4d3305a02" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "2a5df7adc5462996bc9da6eb4c31c4d4d3305a02" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySessionGate.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySessionGate.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "e40f1c1baa48a335cc3ba9321acafa8ff7ede4a9" + ], + "core": [ + "100644", + "e40f1c1baa48a335cc3ba9321acafa8ff7ede4a9" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e40f1c1baa48a335cc3ba9321acafa8ff7ede4a9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySignOutService.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/ElsaIdentitySignOutService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "f659b4eefa87ab61ad137c9bea387a98840f3a5a" + ], + "core": [ + "100644", + "199f87d75b367ff508a70d31eb0d3e4ee2fc9c00" + ], + "disposition": "retained-core-improvement", + "note": "Retain token-clear retry, fail-closed navigation on failure, and hosted PathBase redirect.", + "result": [ + "100644", + "199f87d75b367ff508a70d31eb0d3e4ee2fc9c00" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/JwtTokenProvider.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/Services/JwtTokenProvider.cs", + "beforeSource": [ + "100644", + "1c163f76f99769b69cce30eeace98430cba929ae" + ], + "afterSource": [ + "100644", + "1f5edc3a0046790971eb30304a86dcb4a357d803" + ], + "core": [ + "100644", + "5ede85b3029171b94fb868ad9bb50d6c42fd47bc" + ], + "disposition": "retained-core-improvement", + "note": "Retain refresh permission invalidation and original constructor compatibility overload.", + "result": [ + "100644", + "5ede85b3029171b94fb868ad9bb50d6c42fd47bc" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.ElsaIdentity/TokenNames.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.ElsaIdentity/TokenNames.cs", + "beforeSource": [ + "100644", + "0f27ae83f1d534e4e8be916af3eca84848ecff91" + ], + "afterSource": [ + "100644", + "9bb6eb70e6cc12d29b7e779b2d09c0edb836a61e" + ], + "core": [ + "100644", + "13d03f973e35e6abfc6f0cdfa911f8d6974f4e37" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "13d03f973e35e6abfc6f0cdfa911f8d6974f4e37" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/OpenIdConnectUserMenu.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/OpenIdConnectUserMenu.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "159dd710fd68ae88ac8695972c3a34992940e552" + ], + "core": [ + "100644", + "159dd710fd68ae88ac8695972c3a34992940e552" + ], + "disposition": "upstream-identical", + "note": "Source now uses the existing canonical Core Navigation.BaseUri PathBase action byte-for-byte.", + "result": [ + "100644", + "159dd710fd68ae88ac8695972c3a34992940e552" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/AuthenticationController.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/AuthenticationController.cs", + "beforeSource": [ + "100644", + "7b8f8994c3bcd22103e5aa4bdf01f5d70783f3a4" + ], + "afterSource": [ + "100644", + "fb40afca330c214a4954f592800bfecd098fd672" + ], + "core": [ + "100644", + "98caee4bd7d8efe1619259b622d9d36550aba3f1" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "98caee4bd7d8efe1619259b622d9d36550aba3f1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "8ff9d889ff6a32ed667a55cb7ba1dbd147a6da18" + ], + "afterSource": [ + "100644", + "866a70762522d87712e3ea69bd13f08e6dc02eaa" + ], + "core": [ + "100644", + "1e46cc5bad360fb04a896123b360386a6dfcdfd7" + ], + "disposition": "retained-core-improvement", + "note": "Retain MVC view-feature registration required for ValidateAntiForgeryToken.", + "result": [ + "100644", + "1e46cc5bad360fb04a896123b360386a6dfcdfd7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/HostedAuthenticationPaths.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/HostedAuthenticationPaths.cs", + "disposition": "upstream-identical", + "note": "Existing canonical Core PathBase helper reused byte-for-byte in source; no Core runtime change required.", + "beforeSource": null, + "afterSource": [ + "100644", + "8f3c395718745df4fcbb456843564997d4539e75" + ], + "core": [ + "100644", + "8f3c395718745df4fcbb456843564997d4539e75" + ], + "result": [ + "100644", + "8f3c395718745df4fcbb456843564997d4539e75" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/OpenIdConnectBlazorServerFeature.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/OpenIdConnectBlazorServerFeature.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "0e7313273b43f35139b7a6dac7782d16cacb5656" + ], + "core": [ + "100644", + "0e7313273b43f35139b7a6dac7782d16cacb5656" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0e7313273b43f35139b7a6dac7782d16cacb5656" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/README.md", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/README.md", + "beforeSource": [ + "100644", + "75a3d490aa403880770af7766546d0dfc9f62808" + ], + "afterSource": [ + "100644", + "9104c25fa481d6876b2c777a0fa1c508d14f1835" + ], + "core": [ + "100644", + "9104c25fa481d6876b2c777a0fa1c508d14f1835" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9104c25fa481d6876b2c777a0fa1c508d14f1835" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcSignOutEvents.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcSignOutEvents.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "8f4c4510953471844cf91db0fc35c7b1ea927d55" + ], + "core": [ + "100644", + "8f4c4510953471844cf91db0fc35c7b1ea927d55" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8f4c4510953471844cf91db0fc35c7b1ea927d55" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Components/OpenIdConnectUserMenu.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Components/OpenIdConnectUserMenu.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "7ac92402cb31c651debd047b096f841c39dcafb4" + ], + "core": [ + "100644", + "7ac92402cb31c651debd047b096f841c39dcafb4" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "7ac92402cb31c651debd047b096f841c39dcafb4" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/OpenIdConnectBlazorWasmFeature.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/OpenIdConnectBlazorWasmFeature.cs", + "beforeSource": [ + "100644", + "c8c47dd5ba0985e9927a3a0e870a23c63a192787" + ], + "afterSource": [ + "100644", + "b095ee3a0e8ddf651c7d85f5d0660a570b9f7498" + ], + "core": [ + "100644", + "103889de0a39aff53ef3b3742b8425aa857416b3" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "103889de0a39aff53ef3b3742b8425aa857416b3" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/README.md", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/README.md", + "beforeSource": [ + "100644", + "f2c147dd79b546ee8921f1bc78602ab4033d3b98" + ], + "afterSource": [ + "100644", + "20e43b66aca7ae45e0e007ea5ad0a886c3a55822" + ], + "core": [ + "100644", + "20e43b66aca7ae45e0e007ea5ad0a886c3a55822" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "20e43b66aca7ae45e0e007ea5ad0a886c3a55822" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md", + "destination": "src/studio/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md", + "beforeSource": [ + "100644", + "84b03892e2b92e1fc677d792ef6c072516b5cb0f" + ], + "afterSource": [ + "100644", + "eebfc512b0f44af7a55c66c8a3bf63ee299c09ce" + ], + "core": [ + "100644", + "eebfc512b0f44af7a55c66c8a3bf63ee299c09ce" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "eebfc512b0f44af7a55c66c8a3bf63ee299c09ce" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.UI.Tests/Elsa.Studio.Authentication.UI.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Authentication.UI.Tests/Elsa.Studio.Authentication.UI.Tests.csproj", + "beforeSource": [ + "100644", + "92cd1c21b0161ef10549e22304bac666e412b4de" + ], + "afterSource": [ + "100644", + "61536d630d542008f0a22cce20afe226874c2c7c" + ], + "core": [ + "100644", + "2aa0db5771009939d1cb80df45b267a8ae487ecc" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "2aa0db5771009939d1cb80df45b267a8ae487ecc" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.UI.Tests/ElsaIdentityLoginMethodTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Authentication.UI.Tests/ElsaIdentityLoginMethodTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4478b7980c6d83e76151ff6c2ffe4165e42b4bf6" + ], + "core": [ + "100644", + "4478b7980c6d83e76151ff6c2ffe4165e42b4bf6" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4478b7980c6d83e76151ff6c2ffe4165e42b4bf6" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.UI/Components/LoginMethodButton.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.UI/Components/LoginMethodButton.razor", + "beforeSource": [ + "100644", + "874b23a608295eee2121fd09d3ef7b5c1ee2f940" + ], + "afterSource": [ + "100644", + "e4d8d1592fc83b57f01e6ac64f28eacea9571495" + ], + "core": [ + "100644", + "e4d8d1592fc83b57f01e6ac64f28eacea9571495" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e4d8d1592fc83b57f01e6ac64f28eacea9571495" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.UI/Components/LoginPanel.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.UI/Components/LoginPanel.razor", + "beforeSource": [ + "100644", + "e8c2d00ebaf3e8bca3793c3d6cf6e4054a04c3c6" + ], + "afterSource": [ + "100644", + "2c9f88045b8159813b62abaa1065185026ed75dc" + ], + "core": [ + "100644", + "2c9f88045b8159813b62abaa1065185026ed75dc" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "2c9f88045b8159813b62abaa1065185026ed75dc" + ] + }, + { + "source": "src/modules/Elsa.Studio.Authentication.UI/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Authentication.UI/_Imports.razor", + "beforeSource": [ + "100644", + "7de5c830487a3d23f179369b2058f09a89b566f7" + ], + "afterSource": [ + "100644", + "814baa6b015faa65e3ff00ae748176d3c60a277b" + ], + "core": [ + "100644", + "814baa6b015faa65e3ff00ae748176d3c60a277b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "814baa6b015faa65e3ff00ae748176d3c60a277b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/DashboardPagePermissionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardPagePermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "4628309c76e1f5a876149060281777e1469653d6" + ], + "core": [ + "100644", + "bcafaa3d61d6764a72b3bfd3e2569f2fa8afe9a8" + ], + "disposition": "retained-core-improvement", + "note": "Retain deterministic asynchronous disposal test using Task.Yield instead of global GC.", + "result": [ + "100644", + "bcafaa3d61d6764a72b3bfd3e2569f2fa8afe9a8" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/DashboardStatusTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardStatusTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "a337141b53db01aadfe9735531f8f4db56220426" + ], + "core": [ + "100644", + "93e257e0b69af91d7a8824fc6acfa7611fe61894" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "93e257e0b69af91d7a8824fc6acfa7611fe61894" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/DashboardWelcomeTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWelcomeTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "a9503ed1510d6410a5112fb18b65bb9259c08512" + ], + "core": [ + "100644", + "a9503ed1510d6410a5112fb18b65bb9259c08512" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a9503ed1510d6410a5112fb18b65bb9259c08512" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetPermissionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetPermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "1ab1ad4e027930b42b0a4dd99bd048096c012c8b" + ], + "core": [ + "100644", + "1ab1ad4e027930b42b0a4dd99bd048096c012c8b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "1ab1ad4e027930b42b0a4dd99bd048096c012c8b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs", + "beforeSource": [ + "100644", + "27eb7ca5aeec87c16da916e273ef8bef2798005c" + ], + "afterSource": [ + "100644", + "fc249e43d38c01a82daeeaf95a3930569755149b" + ], + "core": [ + "100644", + "bf38b76b4f1024d6bc3287a54d728911bc1dee48" + ], + "disposition": "merged-upstream-with-core-layout", + "note": "Add incoming static catalog widget regression while retaining consolidated source-layout assertions.", + "result": [ + "100644", + "544ae1219a0e3ca5fd321e762bfb2ad021c4f33c" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/Elsa.Studio.Dashboard.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/Elsa.Studio.Dashboard.Tests.csproj", + "beforeSource": [ + "100644", + "465f540202324c8c0271299e0fff29d448473cfb" + ], + "afterSource": [ + "100644", + "2cf8bf044eabf525178ec79c04963fe9f6d18c04" + ], + "core": [ + "100644", + "b60b85f2ca053e15cc4e067212722b42fad42b23" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "b60b85f2ca053e15cc4e067212722b42fad42b23" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard.Tests/StubFeatureService.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard.Tests/StubFeatureService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "d4054b79832b54a8998556c6c12dcc0b50f64a5b" + ], + "core": [ + "100644", + "d4054b79832b54a8998556c6c12dcc0b50f64a5b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d4054b79832b54a8998556c6c12dcc0b50f64a5b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Components/DashboardWelcome.razor", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Components/DashboardWelcome.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "21a16c40c3167e8dbdcd599c877f47febdb9be13" + ], + "core": [ + "100644", + "21a16c40c3167e8dbdcd599c877f47febdb9be13" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "21a16c40c3167e8dbdcd599c877f47febdb9be13" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/DashboardPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/DashboardPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "5cf90bd0fe7177ee2811def4a36e9a253fee5b83" + ], + "core": [ + "100644", + "5cf90bd0fe7177ee2811def4a36e9a253fee5b83" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "5cf90bd0fe7177ee2811def4a36e9a253fee5b83" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "48750d9f7c9d7d9b30a6d7a4fbbec1f7b36e349f" + ], + "afterSource": [ + "100644", + "7d5c290917571c6c4877ea7c182c0b004f773aa5" + ], + "core": [ + "100644", + "7d5c290917571c6c4877ea7c182c0b004f773aa5" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "7d5c290917571c6c4877ea7c182c0b004f773aa5" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Menu/DashboardMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Menu/DashboardMenu.cs", + "beforeSource": [ + "100644", + "b0d53ed7e67efd685d4962e11b4fdc88406e031d" + ], + "afterSource": [ + "100644", + "ca5b07c31df8b12fd40bda360f2be5507938e63b" + ], + "core": [ + "100644", + "ca5b07c31df8b12fd40bda360f2be5507938e63b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ca5b07c31df8b12fd40bda360f2be5507938e63b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Models/DashboardModels.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Models/DashboardModels.cs", + "beforeSource": [ + "100644", + "a07757fde85291ac8a7f353cd82a3adebb51ce68" + ], + "afterSource": [ + "100644", + "22df406f305d26b051ba5a2d0f3b85474c25cc48" + ], + "core": [ + "100644", + "22df406f305d26b051ba5a2d0f3b85474c25cc48" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "22df406f305d26b051ba5a2d0f3b85474c25cc48" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Pages/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Pages/Index.razor", + "beforeSource": [ + "100644", + "cea5be4d386cc918dc9cf476e5eb56335098da27" + ], + "afterSource": [ + "100644", + "9fda02fa0a6eed2d41736f6c9a5fd7582f4f5869" + ], + "core": [ + "100644", + "9fda02fa0a6eed2d41736f6c9a5fd7582f4f5869" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9fda02fa0a6eed2d41736f6c9a5fd7582f4f5869" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Pages/Index.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Pages/Index.razor.cs", + "beforeSource": [ + "100644", + "3b427f62701833bdafbf995eb30a4d37b0505071" + ], + "afterSource": [ + "100644", + "fb65772c7352ff668bf66eba6dcc58a83bd7ea8f" + ], + "core": [ + "100644", + "dafdebcbac14ddf92f2a828cb0ddb8a1133e2a83" + ], + "disposition": "retained-core-improvement", + "note": "Equivalent data-scope selection and widget lifecycle; retain Core required braces and newer runtime-only coverage.", + "result": [ + "100644", + "dafdebcbac14ddf92f2a828cb0ddb8a1133e2a83" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Pages/Index.razor.css", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Pages/Index.razor.css", + "beforeSource": [ + "100644", + "ff0556d4f1919b863ef79ef4d96cc7fd4ef8f6df" + ], + "afterSource": [ + "100644", + "8a97f40fe3dc0f77289168d5adb88fa4eef3b12a" + ], + "core": [ + "100644", + "8a97f40fe3dc0f77289168d5adb88fa4eef3b12a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8a97f40fe3dc0f77289168d5adb88fa4eef3b12a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/README.md", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/README.md", + "beforeSource": [ + "100644", + "3b50ded88ef39c62e5fe33aa79248d2450275ba9" + ], + "afterSource": [ + "100644", + "ee7a3cb74d875255392514c1b86a705654c5a62e" + ], + "core": [ + "100644", + "ee7a3cb74d875255392514c1b86a705654c5a62e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ee7a3cb74d875255392514c1b86a705654c5a62e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Services/DashboardService.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Services/DashboardService.cs", + "beforeSource": [ + "100644", + "4da2db79fce10c7d31fdd0ab8f63511667ccbaa5" + ], + "afterSource": [ + "100644", + "32b7dca9d160e34e9884fca1a8b12c1f6ccd3662" + ], + "core": [ + "100644", + "32b7dca9d160e34e9884fca1a8b12c1f6ccd3662" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "32b7dca9d160e34e9884fca1a8b12c1f6ccd3662" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Widgets/DashboardWidgetDescriptor.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Widgets/DashboardWidgetDescriptor.cs", + "beforeSource": [ + "100644", + "4ea56b3c80205ac8cf7f82ecb0c70f057ddd4f87" + ], + "afterSource": [ + "100644", + "a0b698d2cc189c84ca88e5debc76fc20303b9d93" + ], + "core": [ + "100644", + "a0b698d2cc189c84ca88e5debc76fc20303b9d93" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a0b698d2cc189c84ca88e5debc76fc20303b9d93" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/Widgets/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/Widgets/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "f439d72115c45328b04a473d9497655ec7d26bde" + ], + "afterSource": [ + "100644", + "784a1e136af139fcdea77c02e8325524f6649e4b" + ], + "core": [ + "100644", + "784a1e136af139fcdea77c02e8325524f6649e4b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "784a1e136af139fcdea77c02e8325524f6649e4b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Dashboard/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Dashboard/_Imports.razor", + "beforeSource": [ + "100644", + "7a41ec58027aa3ee9e5e9ea867f62d97b12f647a" + ], + "afterSource": [ + "100644", + "17be7dba7f862866dac903aaa38242426ea715c8" + ], + "core": [ + "100644", + "9ad896a7e7c381dde3c5536a9aa7af340fad8cbe" + ], + "disposition": "equivalent-razor-import-order", + "note": "Retain Core explicit module imports and BOM/order; same declarations and permission attribute, with required namespace visible in consolidated compilation.", + "result": [ + "100644", + "9ad896a7e7c381dde3c5536a9aa7af340fad8cbe" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Dashboard/Feature.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Dashboard/Feature.cs", + "beforeSource": [ + "100644", + "fa9dc975be8ca7e2745c90e98c420239cd5f487a" + ], + "afterSource": [ + "100644", + "e502f7987c430baabc718ca9bb220bd1c310368a" + ], + "core": [ + "100644", + "e502f7987c430baabc718ca9bb220bd1c310368a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e502f7987c430baabc718ca9bb220bd1c310368a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Dashboard/UI/Dashboard/ConsoleLogsDashboardWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Dashboard/UI/Dashboard/ConsoleLogsDashboardWidget.razor", + "beforeSource": [ + "100644", + "bafb2870f60ea08924091a234e5b0a808c3a7d01" + ], + "afterSource": [ + "100644", + "cd488f46ab4d793217fbaa625280bca7cfcf0fdd" + ], + "core": [ + "100644", + "69dc4a135bbeaec45af0986f757ec45631aa51ba" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "69dc4a135bbeaec45af0986f757ec45631aa51ba" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Tests/ConsoleLogsWidgetPermissionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Tests/ConsoleLogsWidgetPermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "fa69e79075ca522406e5c7e28a35d53ededabc5b" + ], + "core": [ + "100644", + "fa69e79075ca522406e5c7e28a35d53ededabc5b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "fa69e79075ca522406e5c7e28a35d53ededabc5b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Tests/Elsa.Studio.Diagnostics.ConsoleLogs.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs.Tests/Elsa.Studio.Diagnostics.ConsoleLogs.Tests.csproj", + "beforeSource": [ + "100644", + "81d2c7d9e113d4914fb06a359cc09b758f6f459d" + ], + "afterSource": [ + "100644", + "91dea993a9013f4093579654f4c3f7ddd14d9bd9" + ], + "core": [ + "100644", + "144a60438646b9d98d3f8148ac84f814f8b534b8" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "144a60438646b9d98d3f8148ac84f814f8b534b8" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/ConsoleLogsPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/ConsoleLogsPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "8ed7978674c99636219bd97bf32a6363bd46a560" + ], + "core": [ + "100644", + "8ed7978674c99636219bd97bf32a6363bd46a560" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8ed7978674c99636219bd97bf32a6363bd46a560" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/Menu/ConsoleLogsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/Menu/ConsoleLogsMenu.cs", + "beforeSource": [ + "100644", + "59293b1752b74d28c98cc2a56209db9c0bcc4686" + ], + "afterSource": [ + "100644", + "23c1158a31de3411d5c978b220bd811e46b57986" + ], + "core": [ + "100644", + "23c1158a31de3411d5c978b220bd811e46b57986" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "23c1158a31de3411d5c978b220bd811e46b57986" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Pages/ConsoleLogs.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Pages/ConsoleLogs.razor", + "beforeSource": [ + "100644", + "dea9f5a76f283f06119762238dcfcd94380f9d6b" + ], + "afterSource": [ + "100644", + "435788dd89e6ccac48a151814d030d38fefd8839" + ], + "core": [ + "100644", + "435788dd89e6ccac48a151814d030d38fefd8839" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "435788dd89e6ccac48a151814d030d38fefd8839" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Widgets/WorkflowInstanceConsoleLogsLeftPanelTabWidget.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Widgets/WorkflowInstanceConsoleLogsLeftPanelTabWidget.cs", + "beforeSource": [ + "100644", + "bad3c04c49203e2fbd103ae730d0aae7fec0907d" + ], + "afterSource": [ + "100644", + "0e69c055d2e04bb447e925d3992f51b2d03705ca" + ], + "core": [ + "100644", + "0e69c055d2e04bb447e925d3992f51b2d03705ca" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0e69c055d2e04bb447e925d3992f51b2d03705ca" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Widgets/WorkflowInstanceConsoleLogsTabWidget.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/UI/Widgets/WorkflowInstanceConsoleLogsTabWidget.cs", + "beforeSource": [ + "100644", + "f1d99d60285b16f0590be675f282e978b9c03fc3" + ], + "afterSource": [ + "100644", + "4bb2d6c510c11414271b0300f4ff3d0021d52e7e" + ], + "core": [ + "100644", + "4bb2d6c510c11414271b0300f4ff3d0021d52e7e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4bb2d6c510c11414271b0300f4ff3d0021d52e7e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.ConsoleLogs/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.ConsoleLogs/_Imports.razor", + "beforeSource": [ + "100644", + "b2f33cc7fde454ccdd37ca2247c37a9858393264" + ], + "afterSource": [ + "100644", + "06a4417693f53333b9b954c8baf5a6208f0ff5d5" + ], + "core": [ + "100644", + "cfa682e3d1f3970d6a64fd84049e73a8a5d89ca4" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "cfa682e3d1f3970d6a64fd84049e73a8a5d89ca4" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry.Dashboard/Feature.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry.Dashboard/Feature.cs", + "beforeSource": [ + "100644", + "d69274d00b7765c0c7a162e04ab08c755075c47d" + ], + "afterSource": [ + "100644", + "10d7062d9286ba46f21e7e8ef8cbd97b90eb0f14" + ], + "core": [ + "100644", + "a313580a8fcf0ff98985f377f181cf328a6e0dcc" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "a313580a8fcf0ff98985f377f181cf328a6e0dcc" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry.Dashboard/UI/Dashboard/OpenTelemetryDashboardWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry.Dashboard/UI/Dashboard/OpenTelemetryDashboardWidget.razor", + "beforeSource": [ + "100644", + "79b3bbd802e1f9238f28cc9427495581f96d7abb" + ], + "afterSource": [ + "100644", + "6a571fbfe0a29f20de73f1af69d87e6f84647b8e" + ], + "core": [ + "100644", + "6a571fbfe0a29f20de73f1af69d87e6f84647b8e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "6a571fbfe0a29f20de73f1af69d87e6f84647b8e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry/Menu/OpenTelemetryMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry/Menu/OpenTelemetryMenu.cs", + "beforeSource": [ + "100644", + "d2b07c17f636527ad61c45f3ef731412cf5c9e43" + ], + "afterSource": [ + "100644", + "150a9b1cd5c1406200d32be53c52b175cef80b81" + ], + "core": [ + "100644", + "150a9b1cd5c1406200d32be53c52b175cef80b81" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "150a9b1cd5c1406200d32be53c52b175cef80b81" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry/OpenTelemetryPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry/OpenTelemetryPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "42d328a9d70f1b8f6a02db626cc45b19acd8b2b1" + ], + "core": [ + "100644", + "42d328a9d70f1b8f6a02db626cc45b19acd8b2b1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "42d328a9d70f1b8f6a02db626cc45b19acd8b2b1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry/UI/Pages/OpenTelemetry.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry/UI/Pages/OpenTelemetry.razor", + "beforeSource": [ + "100644", + "db89247183b85008d57686add97cc88ff0ee5d8c" + ], + "afterSource": [ + "100644", + "305bfbc50c21143601c22d7f2317e901681ca3aa" + ], + "core": [ + "100644", + "305bfbc50c21143601c22d7f2317e901681ca3aa" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "305bfbc50c21143601c22d7f2317e901681ca3aa" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.OpenTelemetry/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.OpenTelemetry/_Imports.razor", + "beforeSource": [ + "100644", + "da41bc3a9997072061285b7f5ebaa4fc846872c2" + ], + "afterSource": [ + "100644", + "4d62f24a4e30a30a57f058c94b549b68e7611032" + ], + "core": [ + "100644", + "fca40a8d20f7872c29312344336e8a12f5e9e556" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "fca40a8d20f7872c29312344336e8a12f5e9e556" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs.Dashboard/Feature.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs.Dashboard/Feature.cs", + "beforeSource": [ + "100644", + "130019f3acc03ab5ecc1880dfa829f4a303ee9c7" + ], + "afterSource": [ + "100644", + "ded8c4b4cbcf1155c8f515df06201824c62c7571" + ], + "core": [ + "100644", + "ded8c4b4cbcf1155c8f515df06201824c62c7571" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ded8c4b4cbcf1155c8f515df06201824c62c7571" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs.Dashboard/UI/Dashboard/StructuredLogsDashboardWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs.Dashboard/UI/Dashboard/StructuredLogsDashboardWidget.razor", + "beforeSource": [ + "100644", + "5fd079e5afe4d22f6e2a439fdd5cb052a9143666" + ], + "afterSource": [ + "100644", + "8e5b35ea4196b476c389539ca450fe8c650f89a6" + ], + "core": [ + "100644", + "903a78e2ec5e88ebfba6c78a064b3b0b8f11328e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "903a78e2ec5e88ebfba6c78a064b3b0b8f11328e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/Elsa.Studio.Diagnostics.StructuredLogs.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/Elsa.Studio.Diagnostics.StructuredLogs.Tests.csproj", + "beforeSource": [ + "100644", + "7e50bb54b181026f7945cff3624f4abea698b022" + ], + "afterSource": [ + "100644", + "0adcb970cd82af874e9db13017851989d8652481" + ], + "core": [ + "100644", + "383ecdd490ac67ee54b7d875f948055fb6ede2fe" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "383ecdd490ac67ee54b7d875f948055fb6ede2fe" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/StructuredLogsAuthorizationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/StructuredLogsAuthorizationTests.cs", + "beforeSource": [ + "100644", + "482e42e16a40cdbf1b4c9a6888050755a36fef59" + ], + "afterSource": [ + "100644", + "f952d787898236afa5a7d4124c669620544c70fb" + ], + "core": [ + "100644", + "ac1002481a8638d64e2e4faf3e9048afee55d7db" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "ac1002481a8638d64e2e4faf3e9048afee55d7db" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/StructuredLogsPrerenderTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs.Tests/StructuredLogsPrerenderTests.cs", + "beforeSource": [ + "100644", + "23c36d270829474a01e55b1c3550888a92fd2f36" + ], + "afterSource": [ + "100644", + "34f8b1c31f2a82f52569716f996bab900bcd8d14" + ], + "core": [ + "100644", + "8d9a8a08d1a35f40648ce8c56438c4c7ee1146ef" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "8d9a8a08d1a35f40648ce8c56438c4c7ee1146ef" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs/Menu/StructuredLogsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs/Menu/StructuredLogsMenu.cs", + "beforeSource": [ + "100644", + "9057d5ae9fe85da0247421c68557516b1a63ea51" + ], + "afterSource": [ + "100644", + "9b053d7fadff97e355eb0c9e7aa5b96e78ea020d" + ], + "core": [ + "100644", + "9b053d7fadff97e355eb0c9e7aa5b96e78ea020d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9b053d7fadff97e355eb0c9e7aa5b96e78ea020d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs/StructuredLogsPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs/StructuredLogsPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "922d55fd6b6a92ee45301af142332973b95e540a" + ], + "core": [ + "100644", + "922d55fd6b6a92ee45301af142332973b95e540a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "922d55fd6b6a92ee45301af142332973b95e540a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs/UI/Pages/StructuredLogs.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs/UI/Pages/StructuredLogs.razor", + "beforeSource": [ + "100644", + "a46c80641966b9afe93f445645549d04d0481993" + ], + "afterSource": [ + "100644", + "0f821f067497f25a02d13c61e33feb3431769332" + ], + "core": [ + "100644", + "0f821f067497f25a02d13c61e33feb3431769332" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0f821f067497f25a02d13c61e33feb3431769332" + ] + }, + { + "source": "src/modules/Elsa.Studio.Diagnostics.StructuredLogs/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Diagnostics.StructuredLogs/_Imports.razor", + "beforeSource": [ + "100644", + "597f6a2bf4960bd46247a9435dcdce557152952a" + ], + "afterSource": [ + "100644", + "b8d508d040d4dc09d750f1ae742d7dc13392e68c" + ], + "core": [ + "100644", + "331b740f3589034728f76db0aea93b741240cc78" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "331b740f3589034728f76db0aea93b741240cc78" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj", + "beforeSource": [ + "100644", + "61fbf2285ea40a42edb6fe5cacd136edee6f9f72" + ], + "afterSource": [ + "100644", + "e281c73f25d30dfd8e29856f1e4efbe64267b545" + ], + "core": [ + "100644", + "dbcf865b2779d6e88ab2a79ba3fc750a679afbfe" + ], + "disposition": "merged-project-references", + "note": "Retain Core Security reference and add upstream ElsaIdentity.BlazorServer and Core.BlazorServer references for startup regression.", + "result": [ + "100644", + "c9c3d8a84641d81bab4e0e133974614dfeabed3e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "7a3e9743bc767f3d7a616d607882e883d06d7fef" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "7a3e9743bc767f3d7a616d607882e883d06d7fef" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments.Tests/EnvironmentPickerTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments.Tests/EnvironmentPickerTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "b4f92cfea0a3a33b9ed63214b3481c84cb7a17b2" + ], + "core": [ + "100644", + "b4f92cfea0a3a33b9ed63214b3481c84cb7a17b2" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b4f92cfea0a3a33b9ed63214b3481c84cb7a17b2" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments.Tests/EnvironmentsModuleTests.cs", + "beforeSource": [ + "100644", + "9ded9cc4f765b0ebc17ef3f82c097150917855fe" + ], + "afterSource": [ + "100644", + "c3c9a2937100c7bf16c491d7f087b7d28006313e" + ], + "core": [ + "100644", + "c3c9a2937100c7bf16c491d7f087b7d28006313e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c3c9a2937100c7bf16c491d7f087b7d28006313e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments.Tests/FeatureCatalogTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "539ca0e0c773a1430c045b6a28ab4ad3b369ad2f" + ], + "core": [ + "100644", + "81b7cf0eede666f2466857ad7c7c03b4bcb9f73a" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "81b7cf0eede666f2466857ad7c7c03b4bcb9f73a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Components/EnvironmentPicker.razor", + "destination": "src/studio/modules/Elsa.Studio.Environments/Components/EnvironmentPicker.razor", + "beforeSource": [ + "100644", + "657cb6e69149cda6c6bc85bb01474380f5bfedda" + ], + "afterSource": [ + "100644", + "b43631718c81443d962fb24242f89589e627dbcd" + ], + "core": [ + "100644", + "b43631718c81443d962fb24242f89589e627dbcd" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b43631718c81443d962fb24242f89589e627dbcd" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "865a0ca57f33a2e93de6a0ee82477e8e6621ef4f" + ], + "afterSource": [ + "100644", + "2a94c1f8669956a799907c77c38382577d7dd73d" + ], + "core": [ + "100644", + "2a94c1f8669956a799907c77c38382577d7dd73d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "2a94c1f8669956a799907c77c38382577d7dd73d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Module.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments/Module.cs", + "beforeSource": [ + "100644", + "c2aaed199eca69e506c800457570ea42cfca127f" + ], + "afterSource": [ + "100644", + "66e91771d4efdffe04c73ef4b9fc43796d6fc4d8" + ], + "core": [ + "100644", + "66e91771d4efdffe04c73ef4b9fc43796d6fc4d8" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "66e91771d4efdffe04c73ef4b9fc43796d6fc4d8" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments/Services/EnvironmentAwareFeatureService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "c15c9ccb8769155a78a110fd3b76db4907ffe0d6" + ], + "core": [ + "100644", + "c15c9ccb8769155a78a110fd3b76db4907ffe0d6" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c15c9ccb8769155a78a110fd3b76db4907ffe0d6" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments/Services/EnvironmentLoader.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "83793a3e72d83fdfb3c363c978a156f0c6563e63" + ], + "core": [ + "100644", + "83793a3e72d83fdfb3c363c978a156f0c6563e63" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "83793a3e72d83fdfb3c363c978a156f0c6563e63" + ] + }, + { + "source": "src/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs", + "destination": "src/studio/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs", + "beforeSource": [ + "100644", + "8e9d62fb3c0f26ca7e2602b54d008b1fcae30b8c" + ], + "afterSource": null, + "core": [ + "100644", + "8e9d62fb3c0f26ca7e2602b54d008b1fcae30b8c" + ], + "disposition": "applied-upstream-deletion", + "note": "Remove obsolete unregistered host-startup environment task; current circuit-scoped loader remains.", + "result": null + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.BlazorServer/Components/BrokerLoginState.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.BlazorServer/Components/BrokerLoginState.razor", + "beforeSource": [ + "100644", + "91841876fa6c55cc8f8d89d134b009ba5f735d55" + ], + "afterSource": [ + "100644", + "a6d85434c408de962c6d7a461f0dac7a5fb773f4" + ], + "core": [ + "100644", + "a6d85434c408de962c6d7a461f0dac7a5fb773f4" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a6d85434c408de962c6d7a461f0dac7a5fb773f4" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.BlazorWasm/Components/BrokerLoginState.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.BlazorWasm/Components/BrokerLoginState.razor", + "beforeSource": [ + "100644", + "e3ab26666fb3797e977c21b2a648a2486d7f3506" + ], + "afterSource": [ + "100644", + "c04cb88e6e00ee581dcc4edf8d4953b076a5556d" + ], + "core": [ + "100644", + "c04cb88e6e00ee581dcc4edf8d4953b076a5556d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c04cb88e6e00ee581dcc4edf8d4953b076a5556d" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.BlazorWasm/Services/ExternalAuthenticationReturnPath.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.BlazorWasm/Services/ExternalAuthenticationReturnPath.cs", + "beforeSource": [ + "100644", + "9e09d0f1842c40cdc339e527058c8e98f1541a6e" + ], + "afterSource": [ + "100644", + "9f94f694d06d861f8e9cfc40c2e885e64a01abfb" + ], + "core": [ + "100644", + "9f94f694d06d861f8e9cfc40c2e885e64a01abfb" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9f94f694d06d861f8e9cfc40c2e885e64a01abfb" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/BlazorServer/ServerBrokerAuthenticationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/BlazorServer/ServerBrokerAuthenticationTests.cs", + "beforeSource": [ + "100644", + "0a945821ffc881314184b84c67133e42d2281112" + ], + "afterSource": [ + "100644", + "10d30e84f68ff9ad9a7cf8b7e654deef828b2039" + ], + "core": [ + "100644", + "8aa3e0042bca13b6dedf982ab9a6ddfc1119b81c" + ], + "disposition": "retained-core-improvement", + "note": "Retain callback log-redaction regression including forged correlation IDs.", + "result": [ + "100644", + "8aa3e0042bca13b6dedf982ab9a6ddfc1119b81c" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/BlazorWasm/ExternalAuthenticationWasmTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/BlazorWasm/ExternalAuthenticationWasmTests.cs", + "beforeSource": [ + "100644", + "ffc25c7a4bafdcf1d4e193cf6a0823245abad527" + ], + "afterSource": [ + "100644", + "47819809d73839d11a991d90103676bc1af80ebf" + ], + "core": [ + "100644", + "47819809d73839d11a991d90103676bc1af80ebf" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "47819809d73839d11a991d90103676bc1af80ebf" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ApiClientRegistrationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ApiClientRegistrationTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "586a18e7df8152c261adafdc1f271564618c60b9" + ], + "core": [ + "100644", + "9672256047430dd70183030b247616da654e9f4e" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "9672256047430dd70183030b247616da654e9f4e" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/AppBarUserMenuTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/AppBarUserMenuTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "9ae80e73098bf1ce770ace87db252d59d09f0338" + ], + "core": [ + "100644", + "9ae80e73098bf1ce770ace87db252d59d09f0338" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9ae80e73098bf1ce770ace87db252d59d09f0338" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/DirectOpenIdConnectLoginTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/DirectOpenIdConnectLoginTests.cs", + "beforeSource": [ + "100644", + "e0f5e4beba002d40ce3741c0906030ccc37f2073" + ], + "afterSource": [ + "100644", + "ce3dc83a291bfaf4f91bf41e6faa6655b0d89150" + ], + "core": [ + "100644", + "61bce4c2e248673bfd389b07e4d9bd3bc6b54c22" + ], + "disposition": "retained-core-improvement", + "note": "Retain canonical transaction-based OIDC state tests in LegacyOidcAuthorizationStateTests; removed helper is superseded by state validation preserving normalized ReturnPath.", + "result": [ + "100644", + "61bce4c2e248673bfd389b07e4d9bd3bc6b54c22" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ElsaIdentitySignOutTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/ElsaIdentitySignOutTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "93022777447f123e716001fbc56aa3a49db7c126" + ], + "core": [ + "100644", + "527319f4b0d5a189e28587d203211f2df067649b" + ], + "disposition": "retained-core-improvement", + "note": "Retain owned HTTP response teardown, clearing failure coverage, and hosted sign-out behavior.", + "result": [ + "100644", + "527319f4b0d5a189e28587d203211f2df067649b" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "18a1dff8d17f481ded1f6825bec32c31d2355bba" + ], + "core": [ + "100644", + "9ac4795eca17b8a13d56a6927b79e9c5522e85f5" + ], + "disposition": "merged-source-hosted-form-regressions", + "note": "Preserve newer Core MVC antiforgery host wiring and registration test; incorporate source rendered form regressions for root, /studio/, and nested /elsa/studio/ PathBase.", + "result": [ + "100644", + "c5c0ad0640da87bd1f9fb5877ced7da2d94c69bf" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorWasmSignOutTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorWasmSignOutTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "a4574a3bcb1b9ff16eb1b0a808f77908c5dadbcb" + ], + "core": [ + "100644", + "a4574a3bcb1b9ff16eb1b0a808f77908c5dadbcb" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a4574a3bcb1b9ff16eb1b0a808f77908c5dadbcb" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectUserMenuTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectUserMenuTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "d483e987e0ca59afe06f12f63309b8796ac59be2" + ], + "core": [ + "100644", + "d483e987e0ca59afe06f12f63309b8796ac59be2" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d483e987e0ca59afe06f12f63309b8796ac59be2" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "93a44f8e957097d64733abc2acce704fa30ca10e" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "93a44f8e957097d64733abc2acce704fa30ca10e" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Connections/ConnectionEditorTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Connections/ConnectionEditorTests.cs", + "beforeSource": [ + "100644", + "14b38cb2175d9bbf346c9b5180d776dec974beed" + ], + "afterSource": [ + "100644", + "b14ddf806e1460e4178ef9ded14620e1b375d139" + ], + "core": [ + "100644", + "b513e007c33a1e626d4b5299c2f793d161f237ca" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "b513e007c33a1e626d4b5299c2f793d161f237ca" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Connections/ConnectionManagementUiStateTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Connections/ConnectionManagementUiStateTests.cs", + "beforeSource": [ + "100644", + "ee1137ff65a1ff10151d552d42f96f1f78df968f" + ], + "afterSource": [ + "100644", + "9a86ec9a1df622867a9265d71d658aec2b90b347" + ], + "core": [ + "100644", + "9a86ec9a1df622867a9265d71d658aec2b90b347" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9a86ec9a1df622867a9265d71d658aec2b90b347" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj", + "beforeSource": [ + "100644", + "c018b54f52a5230835a43f7283b61f00c0d86396" + ], + "afterSource": [ + "100644", + "fee32aa583d3812bc0df5f3e1396b22775c87f94" + ], + "core": [ + "100644", + "fd3a0df2228a7555892354433b06bcc17883a447" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "fd3a0df2228a7555892354433b06bcc17883a447" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Links/ExternalIdentityLinksTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Links/ExternalIdentityLinksTests.cs", + "beforeSource": [ + "100644", + "8d8fba80d532a39bb7964b94ce8c04d2bf56d7ae" + ], + "afterSource": [ + "100644", + "8667030d5a059ec3a025c18d65ae1f10ed49ec17" + ], + "core": [ + "100644", + "d6f17b0cb26df7fa8d019483c77b0a829d83c9fb" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "d6f17b0cb26df7fa8d019483c77b0a829d83c9fb" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LocalReturnPathCorpus.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LocalReturnPathCorpus.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "0bfdf7e96ec5b4951c828f3da564a8ba0ae19aff" + ], + "core": [ + "100644", + "0bfdf7e96ec5b4951c828f3da564a8ba0ae19aff" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0bfdf7e96ec5b4951c828f3da564a8ba0ae19aff" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LocalReturnPathTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LocalReturnPathTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "0c0b30e845c7e86a44456ca7d0a161d8dea769a7" + ], + "core": [ + "100644", + "0c0b30e845c7e86a44456ca7d0a161d8dea769a7" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0c0b30e845c7e86a44456ca7d0a161d8dea769a7" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LoginChooserTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Login/LoginChooserTests.cs", + "beforeSource": [ + "100644", + "b3272f360c78e2645e60d6bd1a3fcd3f87e14545" + ], + "afterSource": [ + "100644", + "a616a67408db409be4d837939b7f9a6ed65899c3" + ], + "core": [ + "100644", + "8fa9a7385259c8d2f1a2333ec365748dced7d847" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "8fa9a7385259c8d2f1a2333ec365748dced7d847" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/OperationsUiTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/OperationsUiTests.cs", + "beforeSource": [ + "100644", + "5991aa9d9edf36f978fb2cfed10e9947aa31eae3" + ], + "afterSource": [ + "100644", + "565fe920c26e0fa94166c67b91147b9ffa25601e" + ], + "core": [ + "100644", + "565fe920c26e0fa94166c67b91147b9ffa25601e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "565fe920c26e0fa94166c67b91147b9ffa25601e" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs", + "beforeSource": [ + "100644", + "ba934f801f4b11c1a09913db132646cbadfa4f80" + ], + "afterSource": [ + "100644", + "e739535c9bb7786c801c7e910fe8e27f33435277" + ], + "core": [ + "100644", + "0159b60c342e8bbd68f31071588b5da476b3fe61" + ], + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "e739535c9bb7786c801c7e910fe8e27f33435277" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/AccessDeniedPagesTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/AccessDeniedPagesTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "b5584da5750e7896ec546a6038d3ed434b80992c" + ], + "core": [ + "100644", + "d26ce8eec2628e214696038aa87de55d08b45bd6" + ], + "disposition": "retained-core-improvement", + "note": "Retain canonical backend permission snapshot test double and equivalent non-landing-route assertions.", + "result": [ + "100644", + "d26ce8eec2628e214696038aa87de55d08b45bd6" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/ExternalAuthenticationPermissionServiceTests.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/ExternalAuthenticationPermissionServiceTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "2e44c349506e46dd142733d308bbec283a258de9" + ], + "core": [ + "100644", + "c870fca35cc255d8589d17dcd4e6de2986947aa4" + ], + "disposition": "retained-core-improvement", + "note": "Retain shared permission adapter regression coverage including fail-closed unavailable snapshots.", + "result": [ + "100644", + "c870fca35cc255d8589d17dcd4e6de2986947aa4" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/StaticAuthenticationStateProvider.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Permissions/StaticAuthenticationStateProvider.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "ce445cf5c420773fd9ffed924fdfb6922cb1d3a3" + ], + "core": [ + "100644", + "ce445cf5c420773fd9ffed924fdfb6922cb1d3a3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ce445cf5c420773fd9ffed924fdfb6922cb1d3a3" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalAuthenticationConnectionManagementApi.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalAuthenticationConnectionManagementApi.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "db0f2f6fb32ea8c3838c42b0c55ea182a77210b7" + ], + "core": [ + "100644", + "86f0975e6320734636cf04b8a16311941aa484df" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "86f0975e6320734636cf04b8a16311941aa484df" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalAuthenticationConnectionsApi.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalAuthenticationConnectionsApi.cs", + "beforeSource": [ + "100644", + "eabfc9bbd7dd112638aae15388a410b2fa8b6f23" + ], + "afterSource": null, + "core": null, + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": null + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalIdentityLinkManagementApi.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalIdentityLinkManagementApi.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "3bf720597a8d5f9a538b8c811cadcc2eca3bed07" + ], + "core": [ + "100644", + "bdcdf186df16121aefa2534dd537fe0d598b80c1" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "bdcdf186df16121aefa2534dd537fe0d598b80c1" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalIdentityLinksApi.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Client/IExternalIdentityLinksApi.cs", + "beforeSource": [ + "100644", + "0ca8a61fc92b27f22b86873697817cdde9d570fe" + ], + "afterSource": null, + "core": null, + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": null + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Components/IdentityLinks/IdentityLinkDialog.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Components/IdentityLinks/IdentityLinkDialog.razor", + "beforeSource": [ + "100644", + "86b97fff22460339173245622bafda94c5a75901" + ], + "afterSource": [ + "100644", + "c676f857383c474399e4d288de8bce3f6f406b9d" + ], + "core": [ + "100644", + "c676f857383c474399e4d288de8bce3f6f406b9d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c676f857383c474399e4d288de8bce3f6f406b9d" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Components/LoginMethods/BrokerLocalLoginMethod.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Components/LoginMethods/BrokerLocalLoginMethod.razor", + "beforeSource": [ + "100644", + "594f29a4ae85232e8ce959eef00f54a09dbf3f5c" + ], + "afterSource": [ + "100644", + "94c39e71560f8c4717df9595f63a13c8a8d65505" + ], + "core": [ + "100644", + "3deb703fa1c28e2179efbd8de5221df311261006" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "3deb703fa1c28e2179efbd8de5221df311261006" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Extensions/ServiceCollectionExtensions.cs", + "beforeSource": [ + "100644", + "71d95e681f45cfe87b75f8056255e9aa5eaea58b" + ], + "afterSource": [ + "100644", + "14c419bd3a2cbd330d5c2181caaa139d103cdebc" + ], + "core": [ + "100644", + "14c419bd3a2cbd330d5c2181caaa139d103cdebc" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "14c419bd3a2cbd330d5c2181caaa139d103cdebc" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Models/ConnectionModels.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Models/ConnectionModels.cs", + "beforeSource": [ + "100644", + "95fcab14ceab1530186c0a488be5d1da21317c2a" + ], + "afterSource": [ + "100644", + "5f98d68cdd5ef41e7bdacaab9715fc35db32c7b5" + ], + "core": [ + "100644", + "111c0e7f3d7849e6f0a631973e5dfa572e19a9a8" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "111c0e7f3d7849e6f0a631973e5dfa572e19a9a8" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Pages/Connections/Edit.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Pages/Connections/Edit.razor", + "beforeSource": [ + "100644", + "6eb8deb17a0c96ef626a195c658403bfde023756" + ], + "afterSource": [ + "100644", + "f1bd20feef763a5a6ea1426887eff1f10167638b" + ], + "core": [ + "100644", + "7da6ed7baa211c646c420803f17c9143c805d533" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "7da6ed7baa211c646c420803f17c9143c805d533" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Pages/Connections/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Pages/Connections/Index.razor", + "beforeSource": [ + "100644", + "546f4cfc9695056dcfec5d1591fe608bb47a8d9c" + ], + "afterSource": [ + "100644", + "373fe02d541be2086a7f851bf5d52a76a7716777" + ], + "core": [ + "100644", + "8c97dffb906ba294b9e344b264079851d4257ccb" + ], + "disposition": "retained-core-improvement", + "note": "Retain explicit no-read-permission state in addition to upstream page guard.", + "result": [ + "100644", + "8c97dffb906ba294b9e344b264079851d4257ccb" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Pages/IdentityLinks/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Pages/IdentityLinks/Index.razor", + "beforeSource": [ + "100644", + "f6a3ed82ba5297791a767f08142a349b3e78d976" + ], + "afterSource": [ + "100644", + "5ebf55e9769c931858e1140e86e83b2db88e6493" + ], + "core": [ + "100644", + "2a77a13fa1786cf43624201d721b057ceae90608" + ], + "disposition": "retained-core-improvement", + "note": "Retain explicit no-manage-permission state in addition to upstream page guard.", + "result": [ + "100644", + "2a77a13fa1786cf43624201d721b057ceae90608" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Pages/Sessions/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Pages/Sessions/Index.razor", + "beforeSource": [ + "100644", + "507870aacdb6b65b63760307c3d8d59ee8579c51" + ], + "afterSource": [ + "100644", + "9fcc723f44978b7bd076e107a49132bdab002cec" + ], + "core": [ + "100644", + "e25c539395d40c827222d915c2c11990538da81f" + ], + "disposition": "retained-core-improvement", + "note": "Retain explicit no-read-permission state in addition to upstream page guard.", + "result": [ + "100644", + "e25c539395d40c827222d915c2c11990538da81f" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Services/ConnectionActivationWorkflow.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Services/ConnectionActivationWorkflow.cs", + "beforeSource": [ + "100644", + "802a98f3d04337e3fc8c6f808c19a1a5d60ed1ae" + ], + "afterSource": [ + "100644", + "81f5d4f618c0fe10e56957f6aa971f111431972e" + ], + "core": [ + "100644", + "81f5d4f618c0fe10e56957f6aa971f111431972e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "81f5d4f618c0fe10e56957f6aa971f111431972e" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Services/ConnectionManagementUiState.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Services/ConnectionManagementUiState.cs", + "beforeSource": [ + "100644", + "5621818c509b415aba8472c6c9efbce5893f01d4" + ], + "afterSource": [ + "100644", + "8c37f256b6ca629ac752f6dc5c4f12c44b2409bf" + ], + "core": [ + "100644", + "8c37f256b6ca629ac752f6dc5c4f12c44b2409bf" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8c37f256b6ca629ac752f6dc5c4f12c44b2409bf" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Services/ExternalAuthenticationPermissionService.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Services/ExternalAuthenticationPermissionService.cs", + "beforeSource": [ + "100644", + "2edf7b2ccc72a14fe9be28ffc528717f46424e4f" + ], + "afterSource": [ + "100644", + "c84977910f938d3fd2c9c90d89848b8eea69aff0" + ], + "core": [ + "100644", + "c1dff9771973e921d4b0ba7bca4e39f49434b1b0" + ], + "disposition": "retained-core-improvement", + "note": "Retain backend shared permission adapter and none-sentinel filtering instead of JWT-only grant reads.", + "result": [ + "100644", + "c1dff9771973e921d4b0ba7bca4e39f49434b1b0" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/Services/LoginMethodChooserState.cs", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/Services/LoginMethodChooserState.cs", + "beforeSource": [ + "100644", + "aefc2dc2536bc0bd85ed86196d31d4f3495ecd1f" + ], + "afterSource": [ + "100644", + "b0607ffe74a64b7407844037c8cfd63107f9adae" + ], + "core": [ + "100644", + "b0607ffe74a64b7407844037c8cfd63107f9adae" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b0607ffe74a64b7407844037c8cfd63107f9adae" + ] + }, + { + "source": "src/modules/Elsa.Studio.ExternalAuthentication/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.ExternalAuthentication/_Imports.razor", + "beforeSource": [ + "100644", + "15780df7bcc03ab9b7d42d2275e3e791cfe1a0ac" + ], + "afterSource": [ + "100644", + "9a0cdda4a1a4f3b11d28d2693118f2b27dcda69f" + ], + "core": [ + "100644", + "9a0cdda4a1a4f3b11d28d2693118f2b27dcda69f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9a0cdda4a1a4f3b11d28d2693118f2b27dcda69f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/Components/WorkflowDefinitionLabelsEditor.razor", + "destination": "src/studio/modules/Elsa.Studio.Labels/Components/WorkflowDefinitionLabelsEditor.razor", + "beforeSource": [ + "100644", + "5f6f6ca66f3dcb9c56a5eb3a9af401327ebeb21e" + ], + "afterSource": [ + "100644", + "af697ec037ccfeef7afbb3c4d2635ecb90a5b0c4" + ], + "core": [ + "100644", + "af697ec037ccfeef7afbb3c4d2635ecb90a5b0c4" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "af697ec037ccfeef7afbb3c4d2635ecb90a5b0c4" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/Components/WorkflowDefinitionLabelsEditor.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/Components/WorkflowDefinitionLabelsEditor.razor.cs", + "beforeSource": [ + "100644", + "335481bf1c4a7c4d8e4502d6139e83fcfc326c2a" + ], + "afterSource": [ + "100644", + "ff848f9ba177f67c708e069988b2ee8bbae63b49" + ], + "core": [ + "100644", + "ff848f9ba177f67c708e069988b2ee8bbae63b49" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ff848f9ba177f67c708e069988b2ee8bbae63b49" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/LabelPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/LabelPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "3ba0442bfcd847408366c3d68ca8fe91a4615e11" + ], + "core": [ + "100644", + "3ba0442bfcd847408366c3d68ca8fe91a4615e11" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3ba0442bfcd847408366c3d68ca8fe91a4615e11" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/Menu/LabelsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/Menu/LabelsMenu.cs", + "beforeSource": [ + "100644", + "9f34997c2c2d1c70a01c81020ed3ee07643b9195" + ], + "afterSource": [ + "100644", + "3fc3cf19cd2669c95d6c62bec723c00ddcdc1d57" + ], + "core": [ + "100644", + "3fc3cf19cd2669c95d6c62bec723c00ddcdc1d57" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3fc3cf19cd2669c95d6c62bec723c00ddcdc1d57" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/UI/Pages/Label.razor", + "destination": "src/studio/modules/Elsa.Studio.Labels/UI/Pages/Label.razor", + "beforeSource": [ + "100644", + "23da9c37118554a11f8524f34b371d2d8879ad58" + ], + "afterSource": [ + "100644", + "1ee452f9c0e3e203327f3368997b4a384b3ca3b0" + ], + "core": [ + "100644", + "1ee452f9c0e3e203327f3368997b4a384b3ca3b0" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "1ee452f9c0e3e203327f3368997b4a384b3ca3b0" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/UI/Pages/Label.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/UI/Pages/Label.razor.cs", + "beforeSource": [ + "100644", + "a99598391a839d2441ec1ae5e9afdeb7d286ee86" + ], + "afterSource": [ + "100644", + "4e9d0687aebb128b94c47ea4dd3593b2282705e6" + ], + "core": [ + "100644", + "4e9d0687aebb128b94c47ea4dd3593b2282705e6" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "4e9d0687aebb128b94c47ea4dd3593b2282705e6" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/UI/Pages/Labels.razor", + "destination": "src/studio/modules/Elsa.Studio.Labels/UI/Pages/Labels.razor", + "beforeSource": [ + "100644", + "1d961653548a1ff252e6d2fb889d9a5d2997d15b" + ], + "afterSource": [ + "100644", + "7e2ccdf4048ed958fe7ae639202d1f358003495b" + ], + "core": [ + "100644", + "7e2ccdf4048ed958fe7ae639202d1f358003495b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "7e2ccdf4048ed958fe7ae639202d1f358003495b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/UI/Pages/Labels.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/UI/Pages/Labels.razor.cs", + "beforeSource": [ + "100644", + "ccc737abe0ad43d9923f056209728ab95bbfc6fb" + ], + "afterSource": [ + "100644", + "8985df244e46daff9a48bb15c1ecbedc63342126" + ], + "core": [ + "100644", + "be38665d5809e18be169b0b18b74a98f014d7a6f" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "be38665d5809e18be169b0b18b74a98f014d7a6f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/Widgets/WorkflowDefinitionLabelsEditorWidget.cs", + "destination": "src/studio/modules/Elsa.Studio.Labels/Widgets/WorkflowDefinitionLabelsEditorWidget.cs", + "beforeSource": [ + "100644", + "8797af66f908d1deb275394d7bb778a6b518fb19" + ], + "afterSource": [ + "100644", + "b0dde769e9c587e10000642278761e8d2f783516" + ], + "core": [ + "100644", + "b0dde769e9c587e10000642278761e8d2f783516" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b0dde769e9c587e10000642278761e8d2f783516" + ] + }, + { + "source": "src/modules/Elsa.Studio.Labels/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Labels/_Imports.razor", + "beforeSource": [ + "100644", + "87ac067bace118b86f3d95d2d51a335f8727a564" + ], + "afterSource": [ + "100644", + "aff793198fd6d22a48d5e2b0e34c1085103deaa2" + ], + "core": [ + "100644", + "83219cff8c0d3cdac73ae15e5c45cf31f2ce742b" + ], + "disposition": "equivalent-razor-import-order", + "note": "Retain Core explicit module imports and BOM/order; same declarations and permission attribute, with required namespace visible in consolidated compilation.", + "result": [ + "100644", + "83219cff8c0d3cdac73ae15e5c45cf31f2ce742b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Localization.BlazorServer/Controllers/CultureController.cs", + "destination": "src/studio/modules/Elsa.Studio.Localization.BlazorServer/Controllers/CultureController.cs", + "beforeSource": [ + "100644", + "b0390b6f6328a46a924d2df04aa454d364460a8d" + ], + "afterSource": [ + "100644", + "4dae857f4820fad589f79b80d5f3bbf214f1bbe7" + ], + "core": [ + "100644", + "f0cda1c14a25a5a60a0bfea9c7698544c2644541" + ], + "disposition": "retained-core-improvement", + "note": "Retain secure HttpOnly SameSite=Lax culture cookie options.", + "result": [ + "100644", + "f0cda1c14a25a5a60a0bfea9c7698544c2644541" + ] + }, + { + "source": "src/modules/Elsa.Studio.Localization.Tests/Elsa.Studio.Localization.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Localization.Tests/Elsa.Studio.Localization.Tests.csproj", + "beforeSource": null, + "afterSource": [ + "100644", + "3de456bb7d07eb3527c36e7c8bdd177888a90c43" + ], + "core": [ + "100644", + "726dfc4294667aa26e706958b7ec5751f6cdac01" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "726dfc4294667aa26e706958b7ec5751f6cdac01" + ] + }, + { + "source": "src/modules/Elsa.Studio.Localization.Tests/LanguagePickerTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Localization.Tests/LanguagePickerTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "50e0ec00d5f846ca8557741617d70befe185181a" + ], + "core": [ + "100644", + "50e0ec00d5f846ca8557741617d70befe185181a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "50e0ec00d5f846ca8557741617d70befe185181a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Localization/Components/LanguagePicker.razor", + "destination": "src/studio/modules/Elsa.Studio.Localization/Components/LanguagePicker.razor", + "beforeSource": [ + "100644", + "ae92bb8f5a4701e64b244507f90024453cff922f" + ], + "afterSource": [ + "100644", + "403a797f0e90de58765a0d417d1ee03e201b591c" + ], + "core": [ + "100644", + "403a797f0e90de58765a0d417d1ee03e201b591c" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "403a797f0e90de58765a0d417d1ee03e201b591c" + ] + }, + { + "source": "src/modules/Elsa.Studio.Login/Components/LoginState.razor", + "destination": "src/studio/modules/Elsa.Studio.Login/Components/LoginState.razor", + "beforeSource": [ + "100644", + "a92cf98fcada555c8c7c471c58ebbdaa9220a023" + ], + "afterSource": [ + "100644", + "0e998ee1062b0f7ec58a41cba1671f550d959085" + ], + "core": [ + "100644", + "0e998ee1062b0f7ec58a41cba1671f550d959085" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0e998ee1062b0f7ec58a41cba1671f550d959085" + ] + }, + { + "source": "src/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj", + "destination": "src/studio/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj", + "beforeSource": [ + "100644", + "0c53b8c1aefe7ee7c637d4024c42ab531a0b529c" + ], + "afterSource": [ + "100644", + "1c1f7eda69a77a4fac6934af18c63c1079d622ad" + ], + "core": [ + "100644", + "d95149566f581767a1feb4be245999facd87b735" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "d95149566f581767a1feb4be245999facd87b735" + ] + }, + { + "source": "src/modules/Elsa.Studio.Login/Pages/Login/Login.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Login/Pages/Login/Login.razor.cs", + "beforeSource": [ + "100644", + "1cf3c4cb574e539a6ecc720644f72e9b9b180be1" + ], + "afterSource": [ + "100644", + "779064e76264d7af6b1023fb917d9dffb98507b5" + ], + "core": [ + "100644", + "779064e76264d7af6b1023fb917d9dffb98507b5" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "779064e76264d7af6b1023fb917d9dffb98507b5" + ] + }, + { + "source": "src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs", + "destination": "src/studio/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs", + "beforeSource": [ + "100644", + "1c09e3f847d7ad51031a8e981b1d80df48f25c09" + ], + "afterSource": [ + "100644", + "7bed43d855df60e73de5fe85d2ceca0bf5b54b94" + ], + "core": [ + "100644", + "81359034882e1f5bab90248ad34d3e581285d018" + ], + "disposition": "retained-core-improvement", + "note": "Retain cryptographic sessionStorage state transaction, one-time consumption, expiry checks, and safe return path.", + "result": [ + "100644", + "81359034882e1f5bab90248ad34d3e581285d018" + ] + }, + { + "source": "src/modules/Elsa.Studio.PlatformIntegration/Components/SubmitWorkflowDefinitionAction.razor", + "destination": "src/studio/modules/Elsa.Studio.PlatformIntegration/Components/SubmitWorkflowDefinitionAction.razor", + "beforeSource": [ + "100644", + "9b69fef4319353c45c5ad0cb194f22116a5fe32c" + ], + "afterSource": [ + "100644", + "84570d50a87e31320859c8fdb3cd44e3c71ee924" + ], + "core": [ + "100644", + "84570d50a87e31320859c8fdb3cd44e3c71ee924" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "84570d50a87e31320859c8fdb3cd44e3c71ee924" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor", + "destination": "src/studio/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor", + "beforeSource": [ + "100644", + "5c62619934a7cb8bdb4eacfdf90f21b82cd7b2ed" + ], + "afterSource": [ + "100644", + "86701eba7ff9354f6900749d46eca371b9952123" + ], + "core": [ + "100644", + "15f495b2b949a422df70b1af629c548e06ab3a23" + ], + "disposition": "applied-upstream", + "note": "Use authoritative backend inline-create capability; fixture default models current write policy, explicit capability override models varying backend policy without changing the API.", + "result": [ + "100644", + "86701eba7ff9354f6900749d46eca371b9952123" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/Menu/SecretsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Secrets/Menu/SecretsMenu.cs", + "beforeSource": [ + "100644", + "554a29c617f28c3fcbb33f7495bd189c6cd13c51" + ], + "afterSource": [ + "100644", + "91727b348d0a4f6573848b862c3ae3a72dc317c5" + ], + "core": [ + "100644", + "0b1772abebcef90d4b40d1bfc410b76e9b8e2e80" + ], + "disposition": "retained-core-improvement", + "note": "Retain consolidated canonical Elsa.Secrets feature-name support plus upstream legacy feature fallback.", + "result": [ + "100644", + "0b1772abebcef90d4b40d1bfc410b76e9b8e2e80" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/Pages/Secret.razor", + "destination": "src/studio/modules/Elsa.Studio.Secrets/Pages/Secret.razor", + "beforeSource": [ + "100644", + "cd65ea0445755f5d0412429ea64a4246f02e37aa" + ], + "afterSource": [ + "100644", + "e9da50afbd4e8db82a53574e28c0d17e8eeb7a4f" + ], + "core": [ + "100644", + "d2209b6efbf41d95e1f187fc2dbf4116912ee916" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "d2209b6efbf41d95e1f187fc2dbf4116912ee916" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/Pages/Secrets.razor", + "destination": "src/studio/modules/Elsa.Studio.Secrets/Pages/Secrets.razor", + "beforeSource": [ + "100644", + "177c79abeea6685c829d505c0e129dff7548067b" + ], + "afterSource": [ + "100644", + "0b6ccee0014d38c91a2e15343e2fbd25c4b0707f" + ], + "core": [ + "100644", + "b47a400f5fbfcf49836481edac91b59713daf094" + ], + "disposition": "merged-backend-create-capability", + "note": "Apply backend-authoritative create capability from merged source; retain upstream row permission gates and Core formatting.", + "result": [ + "100644", + "0b6ccee0014d38c91a2e15343e2fbd25c4b0707f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/SecretsPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Secrets/SecretsPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "c369da2aef71116839f91ad8e9a08a43a6d32ad3" + ], + "core": [ + "100644", + "c369da2aef71116839f91ad8e9a08a43a6d32ad3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c369da2aef71116839f91ad8e9a08a43a6d32ad3" + ] + }, + { + "source": "src/modules/Elsa.Studio.Secrets/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Secrets/_Imports.razor", + "beforeSource": [ + "100644", + "8f803d5ef0f106332940ffa5999d5fc881956a49" + ], + "afterSource": [ + "100644", + "4810de934b4bbbfe9899c9bcb12f9173739c8594" + ], + "core": [ + "100644", + "4a14565cf8609425b2138fe3e3eeb9bb5cd68a7a" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "4a14565cf8609425b2138fe3e3eeb9bb5cd68a7a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security.Tests/DeleteRoleDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Security.Tests/DeleteRoleDialogTests.cs", + "beforeSource": [ + "100644", + "1e1dee5f44ee772bed5fbceee113455a38b5f3c5" + ], + "afterSource": [ + "100644", + "bbf89fed178b0cff5206808024f0f9b526532d40" + ], + "core": [ + "100644", + "bbf89fed178b0cff5206808024f0f9b526532d40" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "bbf89fed178b0cff5206808024f0f9b526532d40" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security.Tests/IdentityApiErrorMapperTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Security.Tests/IdentityApiErrorMapperTests.cs", + "beforeSource": [ + "100644", + "c5e39c9cc566af7ffb8bf001e96f77183f4b9898" + ], + "afterSource": [ + "100644", + "b835c4b1b13f936d4481079adf24b743c0d8b4f1" + ], + "core": [ + "100644", + "b835c4b1b13f936d4481079adf24b743c0d8b4f1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b835c4b1b13f936d4481079adf24b743c0d8b4f1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security.Tests/RoleEditorSurfaceTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Security.Tests/RoleEditorSurfaceTests.cs", + "beforeSource": [ + "100644", + "1f4bf2616161feb634ee5aa82021c7b51cef6355" + ], + "afterSource": [ + "100644", + "6daf263bd59e333d27f32a0705b3fb5e9556fede" + ], + "core": [ + "100644", + "e42aee7d0aa8aea5481adcf410ed92c29def6e74" + ], + "disposition": "retained-core-improvement", + "note": "Retain extra assertion for visible permission editor action.", + "result": [ + "100644", + "e42aee7d0aa8aea5481adcf410ed92c29def6e74" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security.Tests/RolesPageTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Security.Tests/RolesPageTests.cs", + "beforeSource": [ + "100644", + "1097ec5effb17ee47c59c9a28448985c536e6a4f" + ], + "afterSource": [ + "100644", + "afb1e78e251d7144632d530cd84701ec961be196" + ], + "core": [ + "100644", + "afb1e78e251d7144632d530cd84701ec961be196" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "afb1e78e251d7144632d530cd84701ec961be196" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security.Tests/SecurityMenuAndBoundaryTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Security.Tests/SecurityMenuAndBoundaryTests.cs", + "beforeSource": [ + "100644", + "4b5d7dc187c87660a09eb35e0ebd5c749f97f490" + ], + "afterSource": [ + "100644", + "db983444261217545079df6315310c66765bbdb9" + ], + "core": [ + "100644", + "3c5cea4acdd8e49a7e28bf37679ce41da302eb0d" + ], + "disposition": "retained-core-improvement", + "note": "Retain permission-cache invalidation and queued-refresh editing regressions.", + "result": [ + "100644", + "3c5cea4acdd8e49a7e28bf37679ce41da302eb0d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/DeleteRoleDialog.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/DeleteRoleDialog.razor", + "beforeSource": [ + "100644", + "63d0912f98853c411067c3563bd3e756eca41121" + ], + "afterSource": [ + "100644", + "860244b25bdcf52c4839a79c93a59683cc07ad7f" + ], + "core": [ + "100644", + "860244b25bdcf52c4839a79c93a59683cc07ad7f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "860244b25bdcf52c4839a79c93a59683cc07ad7f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/DeleteRoleDialog.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/DeleteRoleDialog.razor.cs", + "beforeSource": [ + "100644", + "9ad101c9a062f5534f708a109bc7a3b01564b357" + ], + "afterSource": [ + "100644", + "3c04ae5beee51090b2ae62587883296d2f5d25df" + ], + "core": [ + "100644", + "3c04ae5beee51090b2ae62587883296d2f5d25df" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3c04ae5beee51090b2ae62587883296d2f5d25df" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/RoleAdministrationAccessBoundary.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/RoleAdministrationAccessBoundary.razor", + "beforeSource": [ + "100644", + "dfc89554c3f58edaf3be05635a9698d93cdabfa2" + ], + "afterSource": [ + "100644", + "b54abd2a86a3f869214361ff65c9de057031f80a" + ], + "core": [ + "100644", + "4c9ac515fbf9981ceeb6ff3e13def3534b166394" + ], + "disposition": "retained-core-improvement", + "note": "Retain cache invalidation, queued refresh, fail-closed mutation actions during check, and preserved edit state.", + "result": [ + "100644", + "4c9ac515fbf9981ceeb6ff3e13def3534b166394" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/RoleAdvancedGrantCard.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/RoleAdvancedGrantCard.razor", + "beforeSource": [ + "100644", + "7062db0445b357c8d8b33745b778cb5e05b85953" + ], + "afterSource": [ + "100644", + "e65652d858c670f3e7144c7b73f01c662454ee6f" + ], + "core": [ + "100644", + "e65652d858c670f3e7144c7b73f01c662454ee6f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "e65652d858c670f3e7144c7b73f01c662454ee6f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/RoleEditorSurface.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/RoleEditorSurface.razor", + "beforeSource": [ + "100644", + "3b402b8b4e2a6c002c190b420659947045191100" + ], + "afterSource": [ + "100644", + "db4bcd17a32b6d5a781d954bd7cf8a626bed505a" + ], + "core": [ + "100644", + "8721932437a6dbf59e061b960b8d5c1617dafdb9" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "8721932437a6dbf59e061b960b8d5c1617dafdb9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/UserAdministrationAccessBoundary.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/UserAdministrationAccessBoundary.razor", + "beforeSource": [ + "100644", + "2c4579bd23ec032405084100ddbea01add3022e4" + ], + "afterSource": [ + "100644", + "dcb6c3fcc1d17043d68e5c7a265e0d920ee12dcb" + ], + "core": [ + "100644", + "b344a93d026deb532895d6feb4d157efbef3b700" + ], + "disposition": "retained-core-improvement", + "note": "Retain cache invalidation, queued refresh, fail-closed mutation actions during check, and preserved edit state.", + "result": [ + "100644", + "b344a93d026deb532895d6feb4d157efbef3b700" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/UserEditorSurface.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/UserEditorSurface.razor", + "beforeSource": [ + "100644", + "a091862283e71c1367d5a3e42d25f2477ad1c3b4" + ], + "afterSource": [ + "100644", + "fc67c0e72e2c45ed416e780921b7dff162d13605" + ], + "core": [ + "100644", + "fc67c0e72e2c45ed416e780921b7dff162d13605" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "fc67c0e72e2c45ed416e780921b7dff162d13605" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/UserEditorSurface.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/UserEditorSurface.razor.cs", + "beforeSource": [ + "100644", + "9c5d8c3eca8c1043648c0199e2565ebd264d7edd" + ], + "afterSource": [ + "100644", + "93ede0df5390a1936111feb74837010ae5c8f9b3" + ], + "core": [ + "100644", + "93ede0df5390a1936111feb74837010ae5c8f9b3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "93ede0df5390a1936111feb74837010ae5c8f9b3" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/UserListSurface.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/UserListSurface.razor", + "beforeSource": [ + "100644", + "280d12325a6dc50378b315f81b103b268f10cb7e" + ], + "afterSource": [ + "100644", + "20a390d50b44477eeedb1b7b7b6d37c786e5a3f8" + ], + "core": [ + "100644", + "20a390d50b44477eeedb1b7b7b6d37c786e5a3f8" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "20a390d50b44477eeedb1b7b7b6d37c786e5a3f8" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Components/UserListSurface.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Components/UserListSurface.razor.cs", + "beforeSource": [ + "100644", + "61dcb6f3223eab808e8203ab14e1b3b7951b1883" + ], + "afterSource": [ + "100644", + "22af10329daf92448ae57d23a1cc43676a688e73" + ], + "core": [ + "100644", + "22af10329daf92448ae57d23a1cc43676a688e73" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "22af10329daf92448ae57d23a1cc43676a688e73" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Menu/IdentitySecurityMenuContributor.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Menu/IdentitySecurityMenuContributor.cs", + "beforeSource": [ + "100644", + "2dce5968aab852c0df16f245ab4ead4a6e08e81a" + ], + "afterSource": [ + "100644", + "ebb1c92d9dc7ac7013e802806c7e193017c3104e" + ], + "core": [ + "100644", + "ebb1c92d9dc7ac7013e802806c7e193017c3104e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ebb1c92d9dc7ac7013e802806c7e193017c3104e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Models/AdvancedGrantEdit.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Models/AdvancedGrantEdit.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "ee0ba5a560ea1bc3428af029c2c5467c514e5fab" + ], + "core": [ + "100644", + "ee0ba5a560ea1bc3428af029c2c5467c514e5fab" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ee0ba5a560ea1bc3428af029c2c5467c514e5fab" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Pages/Roles.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/Pages/Roles.razor", + "beforeSource": [ + "100644", + "4ae809b6a3f0462643d48cc7de14197955b8e1a1" + ], + "afterSource": [ + "100644", + "11a6855866226103c4e6e0ee11cd6510f61b58c1" + ], + "core": [ + "100644", + "11a6855866226103c4e6e0ee11cd6510f61b58c1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "11a6855866226103c4e6e0ee11cd6510f61b58c1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/Services/IdentityApiErrorMapper.cs", + "destination": "src/studio/modules/Elsa.Studio.Security/Services/IdentityApiErrorMapper.cs", + "beforeSource": [ + "100644", + "5e3540d2ef647e16568d82cab21076a79125044c" + ], + "afterSource": [ + "100644", + "46b9254fa3d21fd776dcc4acbbc902d808bd606c" + ], + "core": [ + "100644", + "46b9254fa3d21fd776dcc4acbbc902d808bd606c" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "46b9254fa3d21fd776dcc4acbbc902d808bd606c" + ] + }, + { + "source": "src/modules/Elsa.Studio.Security/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Security/_Imports.razor", + "beforeSource": [ + "100644", + "33092d5286041c4c00625ff316e1742b497d4f8f" + ], + "afterSource": [ + "100644", + "f2a66d833f7c2f96695916b34e98ac367cde19e0" + ], + "core": [ + "100644", + "61ba1f9dbaa8ac42ab225f3fd010577f5ff21afe" + ], + "disposition": "equivalent-razor-import-order", + "note": "Retain Core explicit module imports and BOM/order; same declarations and permission attribute, with required namespace visible in consolidated compilation.", + "result": [ + "100644", + "61ba1f9dbaa8ac42ab225f3fd010577f5ff21afe" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks.Tests/UserTaskContractTests.cs", + "destination": "src/studio/modules/Elsa.Studio.UserTasks.Tests/UserTaskContractTests.cs", + "beforeSource": [ + "100644", + "4043edb5071dccd9345b830d507d867b2e77f784" + ], + "afterSource": [ + "100644", + "d297352aa82acebcd39578d0501172d9e568fd44" + ], + "core": [ + "100644", + "d297352aa82acebcd39578d0501172d9e568fd44" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d297352aa82acebcd39578d0501172d9e568fd44" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/Menu/UserTasksMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/Menu/UserTasksMenu.cs", + "beforeSource": [ + "100644", + "7f5b09620d7d3653d582adca4ea41363f57e7576" + ], + "afterSource": [ + "100644", + "caff709331f73019d0e36e82afc4642fc17da0dc" + ], + "core": [ + "100644", + "caff709331f73019d0e36e82afc4642fc17da0dc" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "caff709331f73019d0e36e82afc4642fc17da0dc" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/Pages/UserTask.razor", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/Pages/UserTask.razor", + "beforeSource": [ + "100644", + "e4acf332e33541b9097b3b56a005de2b23013d35" + ], + "afterSource": [ + "100644", + "ee765d41d5f7a1c5b86d6dc8b129cdf916afa128" + ], + "core": [ + "100644", + "ee765d41d5f7a1c5b86d6dc8b129cdf916afa128" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "ee765d41d5f7a1c5b86d6dc8b129cdf916afa128" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/Pages/UserTasks.razor", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/Pages/UserTasks.razor", + "beforeSource": [ + "100644", + "85588e181bfda7a39cd1950b9b6dc58013a987d9" + ], + "afterSource": [ + "100644", + "deb9a98b5a64c771dcef88d4284d7f676e54e3cf" + ], + "core": [ + "100644", + "deb9a98b5a64c771dcef88d4284d7f676e54e3cf" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "deb9a98b5a64c771dcef88d4284d7f676e54e3cf" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/Services/UserTaskErrorMapper.cs", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/Services/UserTaskErrorMapper.cs", + "beforeSource": [ + "100644", + "1b56737369b3174205c6773d212687b41ce7ab59" + ], + "afterSource": [ + "100644", + "cb06717343cdff4b027addb7277488b918ebd0d9" + ], + "core": [ + "100644", + "cb06717343cdff4b027addb7277488b918ebd0d9" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cb06717343cdff4b027addb7277488b918ebd0d9" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/UserTaskPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/UserTaskPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "1d7dfa5373832c770175af79a7e81e138a9739c3" + ], + "core": [ + "100644", + "1d7dfa5373832c770175af79a7e81e138a9739c3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "1d7dfa5373832c770175af79a7e81e138a9739c3" + ] + }, + { + "source": "src/modules/Elsa.Studio.UserTasks/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.UserTasks/_Imports.razor", + "beforeSource": [ + "100644", + "3fbcbb938257856c68a9e252af3873fe42793565" + ], + "afterSource": [ + "100644", + "3d2862ee127c3d9d9745173fda9724fda04525dd" + ], + "core": [ + "100644", + "76c83bf9b9cd15c46cb29af9fa8776a2091f8cd2" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "76c83bf9b9cd15c46cb29af9fa8776a2091f8cd2" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Core/Domain/Extensions/ValidationApiExceptionExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Core/Domain/Extensions/ValidationApiExceptionExtensions.cs", + "beforeSource": [ + "100644", + "16b89d21f5eaea6bc62df8a31b98d1f5d071c864" + ], + "afterSource": [ + "100644", + "aaf4f6495b00a8bb14543c6a4010e66bf0dd4d2e" + ], + "core": [ + "100644", + "aaf4f6495b00a8bb14543c6a4010e66bf0dd4d2e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "aaf4f6495b00a8bb14543c6a4010e66bf0dd4d2e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Core/Domain/Services/RemoteBpmnInterchangeService.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Core/Domain/Services/RemoteBpmnInterchangeService.cs", + "beforeSource": [ + "100644", + "f7a8b9d24adc789ccae9bceddbc4f25fdcb62dee" + ], + "afterSource": [ + "100644", + "19155197d117ddd8e0ea9228eda56415699d49d7" + ], + "core": [ + "100644", + "19155197d117ddd8e0ea9228eda56415699d49d7" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "19155197d117ddd8e0ea9228eda56415699d49d7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Core/Domain/Services/WorkflowDefinitionImporter.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Core/Domain/Services/WorkflowDefinitionImporter.cs", + "beforeSource": [ + "100644", + "461451b2b04af278494320d3fa7486cb406ce327" + ], + "afterSource": [ + "100644", + "f5019b5275a4bff66e187b922cea5f425456d2ac" + ], + "core": [ + "100644", + "f5019b5275a4bff66e187b922cea5f425456d2ac" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "f5019b5275a4bff66e187b922cea5f425456d2ac" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Core/WorkflowPermissions.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Core/WorkflowPermissions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "695f68c3496edbc86db9422fe5c176674e6f02c2" + ], + "core": [ + "100644", + "695f68c3496edbc86db9422fe5c176674e6f02c2" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "695f68c3496edbc86db9422fe5c176674e6f02c2" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Dashboard/Feature.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Dashboard/Feature.cs", + "beforeSource": [ + "100644", + "a068aa3e4578e909df0c9abd523c5569e3cde985" + ], + "afterSource": [ + "100644", + "763ff60a3c2b7e3716cd19e56d2b94263532e4f9" + ], + "core": [ + "100644", + "4e2589ef1f703b6b85678e921d5cfc125570bdf1" + ], + "disposition": "retained-core-improvement", + "note": "Equivalent workflow instance permission string through canonical DashboardPermissions.WorkflowInstances constant.", + "result": [ + "100644", + "4e2589ef1f703b6b85678e921d5cfc125570bdf1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardNeedsAttentionWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardNeedsAttentionWidget.razor", + "beforeSource": [ + "100644", + "f3f07132eeab216839641af44707706711cb70ed" + ], + "afterSource": [ + "100644", + "aa68a4c111bd52e9e25ea899aa6a627a28c9b21f" + ], + "core": [ + "100644", + "aa68a4c111bd52e9e25ea899aa6a627a28c9b21f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "aa68a4c111bd52e9e25ea899aa6a627a28c9b21f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardRecentActivityWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardRecentActivityWidget.razor", + "beforeSource": [ + "100644", + "c893a3f40c684828a7b523017c3fd3904c7946e9" + ], + "afterSource": [ + "100644", + "add734c7e3549ff27902c9cfaee05cf9224323bf" + ], + "core": [ + "100644", + "add734c7e3549ff27902c9cfaee05cf9224323bf" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "add734c7e3549ff27902c9cfaee05cf9224323bf" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardTrendWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardTrendWidget.razor", + "beforeSource": [ + "100644", + "70c1a8a7d2d81efb49e48762bbcd8c09394fef23" + ], + "afterSource": [ + "100644", + "37513af0896429b2811677ff2305a256ee2242f4" + ], + "core": [ + "100644", + "37513af0896429b2811677ff2305a256ee2242f4" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "37513af0896429b2811677ff2305a256ee2242f4" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardWorkflowMetricsWidget.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Dashboard/Widgets/DashboardWorkflowMetricsWidget.razor", + "beforeSource": [ + "100644", + "4dc657e09844ffe7646acca53079dd2f976dc0bc" + ], + "afterSource": [ + "100644", + "277b0a83006fc2e71305fd1b41fe54d46a9e1485" + ], + "core": [ + "100644", + "277b0a83006fc2e71305fd1b41fe54d46a9e1485" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "277b0a83006fc2e71305fd1b41fe54d46a9e1485" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "ae73b0711516d06c820eba511d45358abe950132" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "ae73b0711516d06c820eba511d45358abe950132" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "84e65c99e07e42b34caf34c1debce15dda63afe1" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "84e65c99e07e42b34caf34c1debce15dda63afe1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ActivityPickers/TreeviewActivityPickerTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityPickers/TreeviewActivityPickerTests.cs", + "beforeSource": [ + "100644", + "a570712b49d77f65e052a67c3761dda8bb43ce4d" + ], + "afterSource": [ + "100644", + "0fd4add05698ac25d7bec5f88b6d0fd7172c21f6" + ], + "core": [ + "100644", + "4796f9aca34958534664263e2d0948cb7dd21b3f" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "4796f9aca34958534664263e2d0948cb7dd21b3f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerDiagnosticsLoggingTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerDiagnosticsLoggingTests.cs", + "beforeSource": [ + "100644", + "7b82a531a1183e7f541cbe81e65eacb7c3cd6cd0" + ], + "afterSource": [ + "100644", + "448586c42b9caa4c8594f3898558c3262bc1026b" + ], + "core": [ + "100644", + "7ebed9af5209ecca4157548275ff7da0a5eba542" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "7ebed9af5209ecca4157548275ff7da0a5eba542" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerFirstRenderLoadTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerFirstRenderLoadTests.cs", + "beforeSource": [ + "100644", + "9be869b673ac2f231c71553c37b5b23e6de0246c" + ], + "afterSource": [ + "100644", + "e976e238e99f5548b51789efded7d8bdab3e7196" + ], + "core": [ + "100644", + "b391b983e75d4c23c22d1c43afc4ff4e93a90dfb" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "b391b983e75d4c23c22d1c43afc4ff4e93a90dfb" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerUpdateActivitySelectionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerUpdateActivitySelectionTests.cs", + "beforeSource": [ + "100644", + "d446056a5cd5f4d9e30816b7f65efe1553915f8c" + ], + "afterSource": [ + "100644", + "5392341246a5f067c66de37ea4bfd214df080830" + ], + "core": [ + "100644", + "5be3240e6e61572abc3f68063b11f234312a3cb2" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "5be3240e6e61572abc3f68063b11f234312a3cb2" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerWrapperTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDesignerWrapperTests.cs", + "beforeSource": [ + "100644", + "d88baf3960bc64e3cc29106a7f47608988c50120" + ], + "afterSource": [ + "100644", + "9c8529053a4d5a46fe3984203e7a72b206137561" + ], + "core": [ + "100644", + "dc3e2dee3f34eb01d8ed67c6450d60f64abb611b" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "dc3e2dee3f34eb01d8ed67c6450d60f64abb611b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs", + "beforeSource": [ + "100644", + "f7b4e8729dcf841b5363fc0cf25dfce983c7784c" + ], + "afterSource": [ + "100644", + "32997a864e9745ea129a58c5356decf17849efcc" + ], + "core": [ + "100644", + "02d3fb4e204d998f810ebf99bcecb669bcaf9d3e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "02d3fb4e204d998f810ebf99bcecb669bcaf9d3e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerProviderTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerProviderTests.cs", + "beforeSource": [ + "100644", + "acebed4d4032353a66faa20e70dee6dfa57887f4" + ], + "afterSource": [ + "100644", + "91c4c8699bb51d2d3dcc0beb22d499bf5c7eb3d8" + ], + "core": [ + "100644", + "15324d1ef180f0a3753defabe20852a5d13072a8" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "15324d1ef180f0a3753defabe20852a5d13072a8" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerTests.cs", + "beforeSource": [ + "100644", + "6579d7940abaa5bdc2d64cecefba28b9b4822cf0" + ], + "afterSource": [ + "100644", + "a0ff87c211a518f8ace4e8b90cc289529dfd785b" + ], + "core": [ + "100644", + "3ec5eb856640256181a834eb5bd399ea41da17ab" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "3ec5eb856640256181a834eb5bd399ea41da17ab" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnElementSelectionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnElementSelectionTests.cs", + "beforeSource": [ + "100644", + "1f6e1bef9790c80af74d8af6b5d2a0cda7d32aa6" + ], + "afterSource": [ + "100644", + "ad22002a17c9d43ec60045d9325b6b6588c0dac0" + ], + "core": [ + "100644", + "ef756fea25a0ecfd9f042c420b7eb5fc4b0b89ab" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "ef756fea25a0ecfd9f042c420b7eb5fc4b0b89ab" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnImportFindingsDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnImportFindingsDialogTests.cs", + "beforeSource": [ + "100644", + "0904e7508ea8338075a72f0280e6815da44456e9" + ], + "afterSource": [ + "100644", + "f24cebf583d3c9fe09d79f86a7a84cc2a5f4d300" + ], + "core": [ + "100644", + "92a19e598d27ebdda7638f851122b68c9ee83bc6" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "92a19e598d27ebdda7638f851122b68c9ee83bc6" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnImportRefusalDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnImportRefusalDialogTests.cs", + "beforeSource": [ + "100644", + "65a878c6ba2566a7ed753f7ba3cfef6aa4a2555d" + ], + "afterSource": [ + "100644", + "c371865dcc0ae48a7333cffa0975c99805292ab5" + ], + "core": [ + "100644", + "e3d035c5ff2298cc044921d315485f8fb5903a0e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "e3d035c5ff2298cc044921d315485f8fb5903a0e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnImportUiServiceTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnImportUiServiceTests.cs", + "beforeSource": [ + "100644", + "da47e758ee14f29f1bd90d2bc1e64e9851081b42" + ], + "afterSource": [ + "100644", + "013929c21752da9facb8b5cc81c3f97718e76d9b" + ], + "core": [ + "100644", + "29739e913040d9a037b7e2055905383aa2f24872" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "29739e913040d9a037b7e2055905383aa2f24872" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BpmnNestedScopeDrillDownTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BpmnNestedScopeDrillDownTests.cs", + "beforeSource": [ + "100644", + "1855bfe992e3e1db70a46f1bdc80adad65b59bf6" + ], + "afterSource": [ + "100644", + "c36186e51f046aa915956b0920b52ffbc13f2a7a" + ], + "core": [ + "100644", + "1918988fe35b79e5b9384762510cde6325066c0f" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "1918988fe35b79e5b9384762510cde6325066c0f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/BreadcrumbCollectionPortSegmentTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/BreadcrumbCollectionPortSegmentTests.cs", + "beforeSource": [ + "100644", + "e537fdf3a9b5c89e704140858b40740e0298afbe" + ], + "afterSource": [ + "100644", + "cbbc3e4677dce842927326570ed35271b9469edd" + ], + "core": [ + "100644", + "ca61b8675a74aea758a881ed15204a7a9fc04d09" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "ca61b8675a74aea758a881ed15204a7a9fc04d09" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/DataPanelTimestampTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/DataPanelTimestampTests.cs", + "beforeSource": [ + "100644", + "cb69ba9996f85732a82789cf98153e6b1d8baff6" + ], + "afterSource": [ + "100644", + "3b243de0c5bbfd88c53d12ee4493382b816b48dd" + ], + "core": [ + "100644", + "e04cbb6923691e73f35cac22355ac60b941e9b0c" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "e04cbb6923691e73f35cac22355ac60b941e9b0c" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs", + "beforeSource": [ + "100644", + "3be16ba7e92095488cf70b5f4032e0149bb18378" + ], + "afterSource": [ + "100644", + "cad32e1c1ae17705188d6144c607c3e5b56e9afc" + ], + "core": [ + "100644", + "d16276348e5bf68f37981d7aff633fff04e3f9b9" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "d16276348e5bf68f37981d7aff633fff04e3f9b9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/Elsa.Studio.Workflows.Tests.csproj", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/Elsa.Studio.Workflows.Tests.csproj", + "beforeSource": [ + "100644", + "0e60c37e45e291b690aa13ed5769ef771c4f0d78" + ], + "afterSource": [ + "100644", + "abab2711f39005cad79d4add073fe913631db7a8" + ], + "core": [ + "100644", + "d8b03e411b17f76d0b3023ddf160939965051287" + ], + "disposition": "retained-core-project-layout", + "note": "Retain consolidated ProjectReferences, linked shared fixtures, and AngleSharp test dependency. Incoming equivalent test files compile by glob; source standalone solution remains inert.", + "result": [ + "100644", + "d8b03e411b17f76d0b3023ddf160939965051287" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "f452af5544e2bf0626905620441afad3148180fe" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "f452af5544e2bf0626905620441afad3148180fe" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ExportBpmnDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ExportBpmnDialogTests.cs", + "beforeSource": [ + "100644", + "2045ea50b3b201372fa75629dfc1f468871daa2c" + ], + "afterSource": [ + "100644", + "b50ec82e39ad22973d63a835371679528bf63106" + ], + "core": [ + "100644", + "6aae3ee8194e56bbdd7b47d0215e6b7062e70c1f" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "6aae3ee8194e56bbdd7b47d0215e6b7062e70c1f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ExportFlowchartDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ExportFlowchartDialogTests.cs", + "beforeSource": [ + "100644", + "ab2566ad68df3962b53ae157ee58647e2dd01ba0" + ], + "afterSource": [ + "100644", + "518dadb5abe75f350ca175f449d616e8378e22a0" + ], + "core": [ + "100644", + "44bd5d07cd6199c3c4547a7d4ca8169e3cdc480e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "44bd5d07cd6199c3c4547a7d4ca8169e3cdc480e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputConverterSettingsEditorTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputConverterSettingsEditorTests.cs", + "beforeSource": [ + "100644", + "bbd180fced59cd852905f4c3d2e26458bde70ce7" + ], + "afterSource": [ + "100644", + "1a78e5ed3b7dd4035d1b0b3a563b02370cad7892" + ], + "core": [ + "100644", + "a863c1fc647c17da8897f5fb6a9ca83023aa22ab" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "a863c1fc647c17da8897f5fb6a9ca83023aa22ab" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputsTabConverterTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputsTabConverterTests.cs", + "beforeSource": [ + "100644", + "ff50c3aaa3308128c497d41d86e72d33a11f1b92" + ], + "afterSource": [ + "100644", + "1d392008b84add0dee634bc2d2778c9ef92b0e7a" + ], + "core": [ + "100644", + "0121f3092737033554b93c09add855c686787542" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "0121f3092737033554b93c09add855c686787542" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/RemoteBpmnInterchangeServiceTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/RemoteBpmnInterchangeServiceTests.cs", + "beforeSource": [ + "100644", + "bf8c57408fc94c556c873f11ec5e27ab78dce2b8" + ], + "afterSource": [ + "100644", + "8cfb20882f61670e24a5dca1da533ab8c7e254ab" + ], + "core": [ + "100644", + "8cfb20882f61670e24a5dca1da533ab8c7e254ab" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8cfb20882f61670e24a5dca1da533ab8c7e254ab" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/StateMachineDesignerWrapperTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/StateMachineDesignerWrapperTests.cs", + "beforeSource": [ + "100644", + "d3c86d3f2b6018434516db0f56a2af8d560651b9" + ], + "afterSource": [ + "100644", + "9c47bad6bfa705db2b082905d10675a8af90eb46" + ], + "core": [ + "100644", + "70adcd64181debdbfbdb71b69dee6c9bcd5d6f04" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "70adcd64181debdbfbdb71b69dee6c9bcd5d6f04" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/StateMachineDiagramDesignerProviderTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/StateMachineDiagramDesignerProviderTests.cs", + "beforeSource": [ + "100644", + "b05ed7c83e7aafb218a9cde9addbf59c1d0259b7" + ], + "afterSource": [ + "100644", + "60e1e4ad6219d0942d6a7d7273810be60ff661ac" + ], + "core": [ + "100644", + "e64422a0a55df330466c2be0b8f30541b670b050" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "e64422a0a55df330466c2be0b8f30541b670b050" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/StateMachines/BooleanConditionEditorDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/StateMachines/BooleanConditionEditorDialogTests.cs", + "beforeSource": [ + "100644", + "c88d9e3a28ab90b132a32550f7c40a3bb3d70523" + ], + "afterSource": [ + "100644", + "df06bb73809ebb1589b86ff484b8afaad93783df" + ], + "core": [ + "100644", + "75aeda43cbd595cdfc78b4453625f6c94e03c5af" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "75aeda43cbd595cdfc78b4453625f6c94e03c5af" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/StateMachines/StateMachineActivityPickerDialogTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/StateMachines/StateMachineActivityPickerDialogTests.cs", + "beforeSource": [ + "100644", + "c706f9b25d4666b5755ba307ac3ea6554a80d083" + ], + "afterSource": [ + "100644", + "39ed98fc2a992cd3d4fd88f73a48c603eb3cb504" + ], + "core": [ + "100644", + "39ed98fc2a992cd3d4fd88f73a48c603eb3cb504" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "39ed98fc2a992cd3d4fd88f73a48c603eb3cb504" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/StateMachines/StateMachinePresentationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/StateMachines/StateMachinePresentationTests.cs", + "beforeSource": [ + "100644", + "79388196ffe8433d06cb34ce336551a124f7bf97" + ], + "afterSource": [ + "100644", + "5eb4fac7fbbd022fdacf1d110d27e678434a01e9" + ], + "core": [ + "100644", + "5eb4fac7fbbd022fdacf1d110d27e678434a01e9" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "5eb4fac7fbbd022fdacf1d110d27e678434a01e9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/Support/BpmnEditingTestServices.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/Support/BpmnEditingTestServices.cs", + "beforeSource": [ + "100644", + "7216bcf4f988844658844b2dfcc6086d7ac47e68" + ], + "afterSource": [ + "100644", + "edfe514f8d0ffe21b87a25d3c0244b98e4e30c43" + ], + "core": [ + "100644", + "edfe514f8d0ffe21b87a25d3c0244b98e4e30c43" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "edfe514f8d0ffe21b87a25d3c0244b98e4e30c43" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/Support/PopoverTestExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/Support/PopoverTestExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "f25b79ea5e6a197975036eebbced54bc12e35e50" + ], + "core": [ + "100644", + "f25b79ea5e6a197975036eebbced54bc12e35e50" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "f25b79ea5e6a197975036eebbced54bc12e35e50" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/Support/TestDoubles.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/Support/TestDoubles.cs", + "beforeSource": [ + "100644", + "ef406d2221fcecbfed892e99e5d8201082ee6053" + ], + "afterSource": [ + "100644", + "f5de0ce633ce1e27533d8269354ab3723fdaa21c" + ], + "core": [ + "100644", + "2c8f297976463e513028cc7b305ec45e8079c45e" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "2c8f297976463e513028cc7b305ec45e8079c45e" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/UserPermissionsExtensionsTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/UserPermissionsExtensionsTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "70cffc71a0496e6683881b0fc35dda453c4ee6ea" + ], + "core": [ + "100644", + "70cffc71a0496e6683881b0fc35dda453c4ee6ea" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "70cffc71a0496e6683881b0fc35dda453c4ee6ea" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/ValidationApiExceptionExtensionsTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/ValidationApiExceptionExtensionsTests.cs", + "beforeSource": [ + "100644", + "fe0e308491c6111e262ebd77f390b9f347bdb667" + ], + "afterSource": [ + "100644", + "cf0a81cdb8a7efa89a4c740e791ea9fb55198670" + ], + "core": [ + "100644", + "cf0a81cdb8a7efa89a4c740e791ea9fb55198670" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cf0a81cdb8a7efa89a4c740e791ea9fb55198670" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/VariableTypeaheadTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/VariableTypeaheadTests.cs", + "beforeSource": [ + "100644", + "0f593f4c71a1aa468e09ad66e636fd7d26807060" + ], + "afterSource": [ + "100644", + "8161e737d4d1bc2475d92ed9a64de8dd9a11e44a" + ], + "core": [ + "100644", + "53d45ebf97a971b74bd9d07cb820c9f2d6d9a3d9" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "53d45ebf97a971b74bd9d07cb820c9f2d6d9a3d9" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowActionPermissionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowActionPermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "05a089bc0d867fbe04cdd8ecbdbd8fdf0734ae78" + ], + "core": [ + "100644", + "05a089bc0d867fbe04cdd8ecbdbd8fdf0734ae78" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "05a089bc0d867fbe04cdd8ecbdbd8fdf0734ae78" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionEditorParameterLifecycleTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionEditorParameterLifecycleTests.cs", + "beforeSource": [ + "100644", + "222812769dc5318916e1de77fce9f23cf9230aa7" + ], + "afterSource": [ + "100644", + "deaeaad85848c847fe07a273b599e52b6626e46e" + ], + "core": [ + "100644", + "770c073dc39b2dfac90375a6e5d3b64321cc350d" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "770c073dc39b2dfac90375a6e5d3b64321cc350d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionImporterTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionImporterTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "bbc0a6bf2d042c26e72372ac26713dbc8780ed8f" + ], + "core": [ + "100644", + "bbc0a6bf2d042c26e72372ac26713dbc8780ed8f" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "bbc0a6bf2d042c26e72372ac26713dbc8780ed8f" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionMetadataTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowDefinitionMetadataTests.cs", + "beforeSource": [ + "100644", + "c97838bfaad9c0935a61c98bee8af7d2903cdef3" + ], + "afterSource": [ + "100644", + "93fb53ad11d6be3f721caf9d8912f1768ee4438a" + ], + "core": [ + "100644", + "1a7e4e796c1fa72a6a70b9dc1a662a5fcc788b66" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "1a7e4e796c1fa72a6a70b9dc1a662a5fcc788b66" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowDialogValidationTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowDialogValidationTests.cs", + "beforeSource": [ + "100644", + "11deb1725986e210dbc5514262e6f0163bc2f78f" + ], + "afterSource": [ + "100644", + "0f68d47084a988ae2942682e5d4666fbc9ee7978" + ], + "core": [ + "100644", + "22796aff5517d32c4c73d9a5a93fbf67b00abf24" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "22796aff5517d32c4c73d9a5a93fbf67b00abf24" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowEditorLifecycleTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowEditorLifecycleTests.cs", + "beforeSource": [ + "100644", + "c1a8d6459ae3f0b6be9dab0896a39ac0cde1e5d0" + ], + "afterSource": [ + "100644", + "25fc9f7bcc1deb183b8757edc6ddff2ddf4b1b38" + ], + "core": [ + "100644", + "dfaa471e2d67662cf7123815f8dcd0cb5ee845af" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "dfaa471e2d67662cf7123815f8dcd0cb5ee845af" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerDisconnectRefreshTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerDisconnectRefreshTests.cs", + "beforeSource": [ + "100644", + "435f287ebc3af4cb5af3afaac184727423f3c62e" + ], + "afterSource": [ + "100644", + "58e0a1e8df90197b484c638080bd4fe200656ad4" + ], + "core": [ + "100644", + "4bc6f3257ddc86abaf27aa8eb5c122af5cdcaef2" + ], + "disposition": "retained-core-improvement", + "note": "Retain equivalent timer callback waiting/disposal with Core braces and import order; source main regression already ported in a18dc73d0.", + "result": [ + "100644", + "4bc6f3257ddc86abaf27aa8eb5c122af5cdcaef2" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs", + "beforeSource": [ + "100644", + "1b9ae67238364bf0dadb6c0fa7ad78f504faf8ad" + ], + "afterSource": [ + "100644", + "3cadd902a974f073a89457d943472cbefc118c9b" + ], + "core": [ + "100644", + "12a939cb2196b40d6b27a908705acf601de22d8a" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "12a939cb2196b40d6b27a908705acf601de22d8a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceListPollingTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceListPollingTests.cs", + "beforeSource": [ + "100644", + "3bd7296498789997f0f91d7e989c7cf333f9d0c8" + ], + "afterSource": [ + "100644", + "56435d615d55d499a5f3eac3f825f38c842b4774" + ], + "core": [ + "100644", + "d3026015155eabe11be58fe4b757f2a8c25d8070" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "d3026015155eabe11be58fe4b757f2a8c25d8070" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowListPermissionTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowListPermissionTests.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "36b59e45d7aa987085508e53edc155d2261d0e71" + ], + "core": [ + "100644", + "36b59e45d7aa987085508e53edc155d2261d0e71" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "36b59e45d7aa987085508e53edc155d2261d0e71" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows.Tests/WorkflowRootActivityTemplateSelectorTests.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows.Tests/WorkflowRootActivityTemplateSelectorTests.cs", + "beforeSource": [ + "100644", + "fea87bca925e3c11990bba34928ae3bbbad94915" + ], + "afterSource": [ + "100644", + "be38d93731540a15c1cc2c444c02aa264b45df74" + ], + "core": [ + "100644", + "2dd4d52442a432d494cef9be4c76361fd813b48b" + ], + "disposition": "equivalent-imports-or-empty-class-syntax", + "note": "Retain Core import order, unused import differences, formatting/comments, and compilable empty-class syntax. Implementation body equivalent.", + "result": [ + "100644", + "2dd4d52442a432d494cef9be4c76361fd813b48b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/ActivityProperties/ActivityPropertiesPanel.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/ActivityProperties/ActivityPropertiesPanel.razor.cs", + "beforeSource": [ + "100644", + "bdf5134131bc9c460fee5e92647972040ef7a4ff" + ], + "afterSource": [ + "100644", + "1c9b16b7c521c83e69ac8ebc670140e8ab609697" + ], + "core": [ + "100644", + "1c9b16b7c521c83e69ac8ebc670140e8ab609697" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "1c9b16b7c521c83e69ac8ebc670140e8ab609697" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/CodeView.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/CodeView.razor.cs", + "beforeSource": [ + "100644", + "6b242d5be79df6345fa64acbecbfce42b66bd3f3" + ], + "afterSource": [ + "100644", + "b9416787c65d615e7e0edf23c82d824d88cd7556" + ], + "core": [ + "100644", + "b9416787c65d615e7e0edf23c82d824d88cd7556" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b9416787c65d615e7e0edf23c82d824d88cd7556" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowDefinitionVersionViewer.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowDefinitionVersionViewer.razor", + "beforeSource": [ + "100644", + "cbabfd794be8816ef69255a77e04dec6b2e542c2" + ], + "afterSource": [ + "100644", + "711ecc299e07849e66a6dfbee11c210defcffeb3" + ], + "core": [ + "100644", + "711ecc299e07849e66a6dfbee11c210defcffeb3" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "711ecc299e07849e66a6dfbee11c210defcffeb3" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowDefinitionWorkspace.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowDefinitionWorkspace.razor.cs", + "beforeSource": [ + "100644", + "c93f0c34dcdc666382a7384ef20a29f7beaa6168" + ], + "afterSource": [ + "100644", + "b5d74f22189c184e9a301caba041f770085f8e72" + ], + "core": [ + "100644", + "b5d74f22189c184e9a301caba041f770085f8e72" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "b5d74f22189c184e9a301caba041f770085f8e72" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditor.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditor.razor", + "beforeSource": [ + "100644", + "e3ee628a69f09483a7664d1c5e227706d5b9b361" + ], + "afterSource": [ + "100644", + "d26e1c02191ceb96753d8ff94bbece3dcbc5a7f0" + ], + "core": [ + "100644", + "d26e1c02191ceb96753d8ff94bbece3dcbc5a7f0" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d26e1c02191ceb96753d8ff94bbece3dcbc5a7f0" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditor.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditor.razor.cs", + "beforeSource": [ + "100644", + "0182fa856a3569e5fce6682235553b8e5f19af0a" + ], + "afterSource": [ + "100644", + "346f0378659e2af80d10ddc4220d7f4d731aa3e7" + ], + "core": [ + "100644", + "346f0378659e2af80d10ddc4220d7f4d731aa3e7" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "346f0378659e2af80d10ddc4220d7f4d731aa3e7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditorComponentBase.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowEditorComponentBase.cs", + "beforeSource": [ + "100644", + "076063b80aad8e38c2f51bbf7fae21cc13483260" + ], + "afterSource": [ + "100644", + "d462756c2177196d60047a0101d03801e894fa0b" + ], + "core": [ + "100644", + "d462756c2177196d60047a0101d03801e894fa0b" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "d462756c2177196d60047a0101d03801e894fa0b" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowProperties/Tabs/VersionHistory/VersionHistoryTab.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowProperties/Tabs/VersionHistory/VersionHistoryTab.razor", + "beforeSource": [ + "100644", + "3c638ba4b37e8cac49956ec46c5789d12e13fc57" + ], + "afterSource": [ + "100644", + "a53ed3f794dee8a04a3295d6b0f7d36748cba24d" + ], + "core": [ + "100644", + "a53ed3f794dee8a04a3295d6b0f7d36748cba24d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a53ed3f794dee8a04a3295d6b0f7d36748cba24d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowProperties/Tabs/VersionHistory/VersionHistoryTab.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/WorkflowProperties/Tabs/VersionHistory/VersionHistoryTab.razor.cs", + "beforeSource": [ + "100644", + "36574c55fed2185403006cdd5cc7f5f0b651de9b" + ], + "afterSource": [ + "100644", + "df5a0f48b4be142319639a20f0070bc4371d7b21" + ], + "core": [ + "100644", + "df5a0f48b4be142319639a20f0070bc4371d7b21" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "df5a0f48b4be142319639a20f0070bc4371d7b21" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionList/WorkflowDefinitionList.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionList/WorkflowDefinitionList.razor", + "beforeSource": [ + "100644", + "93b80589ca2bf6457993eb55607e278cefe55af8" + ], + "afterSource": [ + "100644", + "2feec60eae554a915f72f1779b852f754154ff90" + ], + "core": [ + "100644", + "2feec60eae554a915f72f1779b852f754154ff90" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "2feec60eae554a915f72f1779b852f754154ff90" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionList/WorkflowDefinitionList.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionList/WorkflowDefinitionList.razor.cs", + "beforeSource": [ + "100644", + "67c23c6a3d18d569ce7bfd73d466387ed1ec6c7d" + ], + "afterSource": [ + "100644", + "6ba55d934dfd4c4e0975436bc748dc09acbf23df" + ], + "core": [ + "100644", + "6ba55d934dfd4c4e0975436bc748dc09acbf23df" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "6ba55d934dfd4c4e0975436bc748dc09acbf23df" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/Components/BulkCancelDialog.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/Components/BulkCancelDialog.razor", + "beforeSource": [ + "100644", + "841302a93f33abc664f047ddf4d493dbff8fa9f4" + ], + "afterSource": [ + "100644", + "faf75c24ceb500bbdaf96e318bea2d3aa0d5c5b4" + ], + "core": [ + "100644", + "c706cf366e5e568816c5d740f08daf91c1f1db83" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "c706cf366e5e568816c5d740f08daf91c1f1db83" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/Components/BulkCancelDialog.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/Components/BulkCancelDialog.razor.cs", + "beforeSource": [ + "100644", + "cf3e790ffa78995ef7b387f69497d70766727235" + ], + "afterSource": [ + "100644", + "c7c17488b2e068d52695eeed6d050cb980c045fb" + ], + "core": [ + "100644", + "8564b409c55355463e5c79fbe72c52fa5890e1d1" + ], + "disposition": "equivalent-formatting-or-comments", + "note": "Retain Core formatting/comments; same implementation tokens.", + "result": [ + "100644", + "8564b409c55355463e5c79fbe72c52fa5890e1d1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/WorkflowInstanceList.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/WorkflowInstanceList.razor", + "beforeSource": [ + "100644", + "93bd40b4d4fa4f20dd5258c9a4e1ded9212f4f84" + ], + "afterSource": [ + "100644", + "a364e1d4b89084ab04909e832f345b30f46e7e90" + ], + "core": [ + "100644", + "a364e1d4b89084ab04909e832f345b30f46e7e90" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "a364e1d4b89084ab04909e832f345b30f46e7e90" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/WorkflowInstanceList.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceList/WorkflowInstanceList.razor.cs", + "beforeSource": [ + "100644", + "da18f27cb6fc80be9aa3057e2882f98e0109c9c3" + ], + "afterSource": [ + "100644", + "aeb77da4d4284d856bb393d7a7b8dcf7f221f1b7" + ], + "core": [ + "100644", + "aeb77da4d4284d856bb393d7a7b8dcf7f221f1b7" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "aeb77da4d4284d856bb393d7a7b8dcf7f221f1b7" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor", + "beforeSource": null, + "afterSource": [ + "100644", + "0fb0469fafe6a1fd91857b5d8a5dc4bea3dca03c" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Import execution drawer behavior and constrain the 600px drawer to the viewport width.", + "result": [ + "100644", + "0fb0469fafe6a1fd91857b5d8a5dc4bea3dca03c" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "a34db80ff2f81458f132de0b0302c84960850b79" + ], + "core": null, + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "a34db80ff2f81458f132de0b0302c84960850b79" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor", + "beforeSource": [ + "100644", + "3cc27b8efe94f9fff9dd283293523bc2c45d3f51" + ], + "afterSource": [ + "100644", + "9eb387c6b52cc502c2b16e42caf1d389923fe15d" + ], + "core": [ + "100644", + "9eb387c6b52cc502c2b16e42caf1d389923fe15d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "9eb387c6b52cc502c2b16e42caf1d389923fe15d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs", + "beforeSource": [ + "100644", + "b6c97f8f8b145b662af23d8797cc2e4624d5635d" + ], + "afterSource": [ + "100644", + "dfe6199f9bf669ff77a3f598208167452ad0ddef" + ], + "core": [ + "100644", + "dfe6199f9bf669ff77a3f598208167452ad0ddef" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "dfe6199f9bf669ff77a3f598208167452ad0ddef" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor", + "beforeSource": [ + "100644", + "1773f48dba74a31b86141a1c747be851cfb613a6" + ], + "afterSource": [ + "100644", + "f0be9a4a2dbb335fc4e48ba699de12519bc07a88" + ], + "core": [ + "100644", + "1773f48dba74a31b86141a1c747be851cfb613a6" + ], + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "f0be9a4a2dbb335fc4e48ba699de12519bc07a88" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs", + "beforeSource": [ + "100644", + "0cf3c572526c446e8baa800ff321cd4ee0fce81e" + ], + "afterSource": [ + "100644", + "bd713f5c7310becbfe1966406c74fbf50afbdb96" + ], + "core": [ + "100644", + "0cf3c572526c446e8baa800ff321cd4ee0fce81e" + ], + "disposition": "applied-upstream", + "note": "Apply missing source behavior or regression fixture to the mapped Core path.", + "result": [ + "100644", + "bd713f5c7310becbfe1966406c74fbf50afbdb96" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/DiagramDesigners/StateMachines/Presentation/StateMachineActivityPickerDialog.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/DiagramDesigners/StateMachines/Presentation/StateMachineActivityPickerDialog.razor", + "beforeSource": [ + "100644", + "29e3fde45aa32d03874b284d2537cf9f09146485" + ], + "afterSource": [ + "100644", + "0490ec25bda24ee7c56b83853348358514a2c91a" + ], + "core": [ + "100644", + "0490ec25bda24ee7c56b83853348358514a2c91a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "0490ec25bda24ee7c56b83853348358514a2c91a" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Extensions/UserPermissionsExtensions.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Extensions/UserPermissionsExtensions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "f6c44363848db823f4f4c14a0b792b429f4fc8f5" + ], + "core": [ + "100644", + "f6c44363848db823f4f4c14a0b792b429f4fc8f5" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "f6c44363848db823f4f4c14a0b792b429f4fc8f5" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Menu/WorkflowsMenu.cs", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Menu/WorkflowsMenu.cs", + "beforeSource": [ + "100644", + "7c502f39428a833571beec0b7340139571fcde58" + ], + "afterSource": [ + "100644", + "be0f96214b15cde06d66ccbcc740cebaf68c2708" + ], + "core": [ + "100644", + "be0f96214b15cde06d66ccbcc740cebaf68c2708" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "be0f96214b15cde06d66ccbcc740cebaf68c2708" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Pages/WorkflowDefinitions/Edit/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Pages/WorkflowDefinitions/Edit/Index.razor", + "beforeSource": [ + "100644", + "baa0bf97403e9991b892213af79d00dfdaae730d" + ], + "afterSource": [ + "100644", + "db9b367540d0b41829d508af7fa548fd20f07254" + ], + "core": [ + "100644", + "db9b367540d0b41829d508af7fa548fd20f07254" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "db9b367540d0b41829d508af7fa548fd20f07254" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Pages/WorkflowDefinitions/List/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Pages/WorkflowDefinitions/List/Index.razor", + "beforeSource": [ + "100644", + "f79e2ccfc028d2d748481e514da8574163cf681c" + ], + "afterSource": [ + "100644", + "3fde97c2952463d05f7b9fa6ae80365658b4d2a1" + ], + "core": [ + "100644", + "3fde97c2952463d05f7b9fa6ae80365658b4d2a1" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "3fde97c2952463d05f7b9fa6ae80365658b4d2a1" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Pages/WorkflowInstances/List/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Pages/WorkflowInstances/List/Index.razor", + "beforeSource": [ + "100644", + "c91e9e6a073ff965fdef274660ff1a452e926fd3" + ], + "afterSource": [ + "100644", + "79960ce11875d9e054ec820d6b1d350479a2657d" + ], + "core": [ + "100644", + "79960ce11875d9e054ec820d6b1d350479a2657d" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "79960ce11875d9e054ec820d6b1d350479a2657d" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/Pages/WorkflowInstances/View/Index.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/Pages/WorkflowInstances/View/Index.razor", + "beforeSource": [ + "100644", + "9c497f0a55d2f72bd7bab5d08335207c4377f4da" + ], + "afterSource": [ + "100644", + "02b25d1b28c6fd1b643ff893d472649d0d7ac1db" + ], + "core": [ + "100644", + "02b25d1b28c6fd1b643ff893d472649d0d7ac1db" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "02b25d1b28c6fd1b643ff893d472649d0d7ac1db" + ] + }, + { + "source": "src/modules/Elsa.Studio.Workflows/_Imports.razor", + "destination": "src/studio/modules/Elsa.Studio.Workflows/_Imports.razor", + "beforeSource": [ + "100644", + "1fccdfe2bfd29a4985e027fe0634fabd5f6aa22c" + ], + "afterSource": [ + "100644", + "eddb2b47d2efadb1bb9827baefd3a601cdda0c45" + ], + "core": [ + "100644", + "435ea57a768b94052ef170e07be2ff3cf0595c30" + ], + "disposition": "equivalent-razor-import-order", + "note": "Retain Core explicit module imports and BOM/order; same declarations and permission attribute, with required namespace visible in consolidated compilation.", + "result": [ + "100644", + "435ea57a768b94052ef170e07be2ff3cf0595c30" + ] + }, + { + "source": "src/testing/ApiExceptions.cs", + "destination": "src/studio/testing/ApiExceptions.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "117c3a00f3e23023e4d8eb4d635eb9ef53cbc792" + ], + "core": [ + "100644", + "ca9246956ed05e8e262677c7665af136d8648c72" + ], + "disposition": "retained-core-improvement", + "note": "Retain scoped request/response disposal in shared exception fixture.", + "result": [ + "100644", + "ca9246956ed05e8e262677c7665af136d8648c72" + ] + }, + { + "source": "src/testing/MenuPopoverAssert.cs", + "destination": "src/studio/testing/MenuPopoverAssert.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "8c0789ac1d45cc30ad600d8cbd9ec3bf51b310d8" + ], + "core": [ + "100644", + "8c0789ac1d45cc30ad600d8cbd9ec3bf51b310d8" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "8c0789ac1d45cc30ad600d8cbd9ec3bf51b310d8" + ] + }, + { + "source": "src/testing/StubPermissionService.cs", + "destination": "src/studio/testing/StubPermissionService.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "c279825e06a08594e50b4640fdf3ecfa460fca6a" + ], + "core": [ + "100644", + "c279825e06a08594e50b4640fdf3ecfa460fca6a" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "c279825e06a08594e50b4640fdf3ecfa460fca6a" + ] + }, + { + "source": "src/testing/TestLocalizer.cs", + "destination": "src/studio/testing/TestLocalizer.cs", + "beforeSource": null, + "afterSource": [ + "100644", + "cf37f7dec6de4e384de4b5fbfb7ef9027ee1b68e" + ], + "core": [ + "100644", + "cf37f7dec6de4e384de4b5fbfb7ef9027ee1b68e" + ], + "disposition": "upstream-identical", + "note": "Exact source blob and mode already present at the mapped Core destination.", + "result": [ + "100644", + "cf37f7dec6de4e384de4b5fbfb7ef9027ee1b68e" + ] + } + ], + "coreMappedDeltas": [ + "3bd9c4d0b8e904c538d9429201e2e9aaf8173c28", + "45490a7e31bf41b0cb067f36ea65cfecfac06c9f", + "6da1a33cbcd81e2881bb517e84d96b4c749ebd36" + ], + "sourceHistoryJoins": [ + "c445f9ffbe53d71e7fa5623f365b5436b7bb499e", + "1b06a98b1dc34085cb3de0730305a99b40a9b767", + "73f8354300270f68031b943e993fbe79326f5946" + ], + "sourceValidation": [ + { + "commit": "cd39f5fc62421ecfb168da0ef6212afb67f10aca", + "command": "dotnet build Elsa.Studio.sln --configuration Release --no-incremental", + "result": "0 errors, 80 warnings" + }, + { + "commit": "1faef9fa54fa12ddc5a345eec5eac018e0a217c0", + "command": "dotnet test Elsa.Studio.sln --configuration Release --no-build --no-restore", + "result": "1900 passed, 0 failed, 0 skipped across 18 TFM results" + }, + { + "commit": "dea56950ab7e269d13d008fd61b2ebd10454843c", + "command": "dotnet build src/modules/Elsa.Studio.Secrets/Elsa.Studio.Secrets.csproj --configuration Release --framework net10.0 --no-restore -p:BuildProjectReferences=false; dotnet test src/modules/Elsa.Studio.Administration.Tests/Elsa.Studio.Administration.Tests.csproj --configuration Release --no-restore -p:BuildProjectReferences=false", + "result": "Secrets build 0 errors; Administration 71 passed" + }, + { + "commit": "2cdfcc525945e99a2ab2c1a02fa3053a0e8dce20", + "command": "dotnet build src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.csproj --configuration Release --framework net10.0 --no-restore -p:BuildProjectReferences=false; dotnet test src/modules/Elsa.Studio.ExternalAuthentication.Tests/Elsa.Studio.ExternalAuthentication.Tests.csproj --configuration Release --no-restore -p:BuildProjectReferences=false --filter FullyQualifiedName~OpenIdConnectBlazorServerSignOutTests", + "result": "OIDC net10.0 build 0 errors; signout tests 16 passed, 0 failed. Before the production fix, the rendered PathBase regression failed for both subpaths and passed for the root path." + }, + { + "commit": "734da587eb3aca7b935375cce60bd536b6afdc6f", + "command": "Hosted build and full solution tests", + "result": "1911 passed, 0 failed, 0 skipped across 18 TFM results; 0 build errors", + "run": "https://github.com/elsa-workflows/elsa-studio/actions/runs/37386031336" + } + ], + "acceptedSourceMain": "3d028a4e9a3267b291f3d43d8064b5c81a6f2759" +} diff --git a/doc/migrations/mongodb-variable-serializer.md b/doc/migrations/mongodb-variable-serializer.md new file mode 100644 index 0000000000..e36c3d1949 --- /dev/null +++ b/doc/migrations/mongodb-variable-serializer.md @@ -0,0 +1,16 @@ +# MongoDB variable serializer construction + +The consolidated MongoDB provider constructs `VariableSerializer` with the application's dependency-injection-registered `ISerializationTypeRegistry`. This preserves custom storage drivers and variable types when persisted variables are restored. + +Code that directly calls the former public parameterless constructor must supply that registry when upgrading: + +```csharp +using Elsa.Common.Serialization; +using Elsa.Persistence.MongoDb.Serializers; +using Microsoft.Extensions.DependencyInjection; + +var serializer = new VariableSerializer( + serviceProvider.GetRequiredService()); +``` + +The constructor also accepts an optional `ILogger`. Applications using the provider's normal MongoDB registration require no manual serializer construction change. Avoid creating a separate default registry: it does not contain the custom types registered by the application. diff --git a/doc/reports/core-release-reconciliation-2026-10-06.md b/doc/reports/core-release-reconciliation-2026-10-06.md new file mode 100644 index 0000000000..d993ab456a --- /dev/null +++ b/doc/reports/core-release-reconciliation-2026-10-06.md @@ -0,0 +1,54 @@ +# Core 3.8.x and 3.9.0 reconciliation + +Scope: reconcile Core release history into main without reverting newer main behavior. Tracker: [#8623](https://github.com/elsa-workflows/elsa-core/issues/8623). + +## Release ancestry + +Main started at `91a1c07232e806436c5e759086bbb9b0aee92234`. The following releases are ancestors after the reconciliation: + +| Core remote ref | Commit | Disposition | +| --- | --- | --- | +| `release/3.8.1` | `249d0f077f8a0b5e6cb1a42b02b49f2f636b5ab5` | One missing backport commit reconciled by merge `e333ee6c9645f4900d046028c6453992fb8bef2d`. | +| `release/3.8.2`, `release/3.8.4` | `33181ae3048f628f591a0155b5665a8e4d1bcea2` | Already ancestors; no merge needed. | +| `release/3.9.0` | `5d3582b6309a2fd8ea33ebdb8c39fb040bfdc514` | Nine missing commits reconciled by merge `290f797ebb09ab597e6fa351497efcc1e18ca560`. | +| Stable tag `3.8.0` | `8191ae30554ea001b38bb44902dd90dc98c7a106` | Already an ancestor; peeled annotated tag commit. | +| Stable tags `3.8.1`, `3.8.2`, `3.8.4` | `33181ae3048f628f591a0155b5665a8e4d1bcea2` | Already ancestors. | +| Stable tag `3.9.0` | `6436609a1a3874d3fea7ccf690897792f5a1f702` | Already an ancestor. | + +Stable tag identities were checked against `git ls-remote --tags origin '3.8.*' '3.9.0'` on 2026-10-06. The Core remote does not advertise a stable `3.8.3` tag; a foreign local tag of that name is excluded. Each commit above was verified with `git merge-base --is-ancestor` against the reconciliation head. + +## Reviewed merge resolutions + +All seven non-merge commits unique to the 3.9.0 release branch have matching stable patch IDs in main: + +| Release commit | Equivalent main commit | +| --- | --- | +| `91f32dafe` | `919fecb3e` | +| `257bebc88` | `2fbeaaa9c` | +| `c9029051d` | `0ac8518af` | +| `1860081f9` | `ce10a3392` | +| `b98590a69` | `43c57da51` | +| `093dc1972` | `082b3ed1b` | +| `4f7fd42e3` | `c9db273a2` | + +The other two missing commits, `7876e41d9` and `5d3582b63`, are release PR merge wrappers. `IdentityBootstrapDiagnostic.cs` is identical between the release tip and main, including the scoped 401/403 explanation, alternate identity provider caveat, and tenant-activation bootstrap timing. + +Conflicts in `src/modules/Elsa.Identity/README.md` and `doc/migrations/authorization-model.md` retained main's generated role IDs and tenant-owned bootstrap behavior introduced by `c34117e52` and refined by `c09f38aff`, `efc508323`, `e3b0db7d5`, and `4d124da89`. Release 3.9's role-name-derived ID collision warning describes the older implementation; applying it as current main behavior would regress the documentation. Main also retains its MongoDB name-index caveats and 3.10 migration guidance. + +The 3.8.1 backport restores ambient tenant stamping, visibility-filtered reads, and tenant-scoped deletes. Main already restored these in [#8434](https://github.com/elsa-workflows/elsa-core/pull/8434), merge `6a323ccc8be6396a817d0055f3b55b8be741fbc1`. Reviewed conflicts retained that implementation's additional `CanReplaceOwnedRow` ownership guard, `TryDeleteAsync`, existing-row tenant preservation, expanded tenant tests, and shared `TestTenantAccessor`. `MemoryStore.Sync` matches the backport. No release fix was discarded. + +Both reconciliation merges have tree `5998b0f7277e15df47dc2e912a2b9fe7e5ae728d`, identical to the starting main tree. This establishes that source, versions, publication settings, and existing tests are unchanged by the ancestry reconciliation. This report is the only subsequent file addition. + +## Verification + +Targeted checks passed on the reconciled source tree under .NET 10: + +| Project / fully qualified name filter | Passed | Failed / skipped | +| --- | --- | --- | +| `test/unit/Elsa.Identity.UnitTests/Elsa.Identity.UnitTests.csproj` / `IdentityBootstrapDiagnosticTests` | 5 | 0 / 0 | +| `test/unit/Elsa.Workflows.Runtime.UnitTests/Elsa.Workflows.Runtime.UnitTests.csproj` / `MemoryKeyValueStoreTenantIsolationTests` | 16 | 0 / 0 | +| `test/unit/Elsa.Common.UnitTests/Elsa.Common.UnitTests.csproj` / `TenantVisibilityTests` | 29 | 0 / 0 | + +Each used `dotnet test --filter FullyQualifiedName~ -p:CollectCoverage=false -v minimal`, with normal restore in the isolated worktree. An initial identity attempt with `--no-restore` failed because the new worktree had no assets file; the normal restore/test retry above passed. Existing identity XML-documentation warnings appeared during the build; no build or test error remained. + +`git diff --check` passed, all audit commit references resolve, and the merge resolution left no conflict markers in the reviewed files. Full solution qualification and independent final integration review remain with the root workroom lead; no full suite, push, publication, or GitHub merge was performed in this bounded slice. diff --git a/src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs b/src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs new file mode 100644 index 0000000000..22f0bf3b5d --- /dev/null +++ b/src/extensions/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_9.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using FluentMigrator; +using JetBrains.Annotations; +using static System.Int32; + +namespace Elsa.Persistence.Dapper.Migrations.Runtime; + +/// +/// Creates the KeyValues table used by DapperKeyValueStore (#263) +/// and adds BookmarkQueueItems.SerializedOptions (#264). +/// Both operations are existence-guarded so hand-created workarounds keep working. +/// +[Migration(20008, "Elsa:Runtime:V3.9")] +[PublicAPI] +[SuppressMessage("ReSharper", "InconsistentNaming")] +public class V3_9 : Migration +{ + /// + public override void Up() + { + if (!Schema.Table("KeyValues").Exists()) + { + Create.Table("KeyValues") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("Value").AsString(MaxValue).Nullable(); + } + + if (!Schema.Table("BookmarkQueueItems").Column("SerializedOptions").Exists()) + { + Alter.Table("BookmarkQueueItems").AddColumn("SerializedOptions").AsString(MaxValue).Nullable(); + } + } + + /// + /// + /// Leave KeyValues and SerializedOptions in place. Rolling them back would + /// drop a hand-created KeyValues table or SerializedOptions column and their data. + /// + public override void Down() + { + } +} diff --git a/src/extensions/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/extensions/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 1739d6d878..926c363d7a 100644 --- a/src/extensions/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/extensions/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -3,8 +3,9 @@ using System.Linq.Expressions; using Elsa.Common.Entities; using Elsa.Common.Models; -using Elsa.Persistence.Dapper.Models; using Elsa.Extensions; +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Models; using JetBrains.Annotations; namespace Elsa.Persistence.Dapper.Extensions; @@ -15,6 +16,8 @@ namespace Elsa.Persistence.Dapper.Extensions; [PublicAPI] public static class ParameterizedQueryBuilderExtensions { + private const char LikeEscapeCharacter = '!'; + /// /// Begins a SELECT FROM query. /// @@ -154,6 +157,17 @@ public static ParameterizedQuery IsNull(this ParameterizedQuery query, string fi return query; } + /// + /// Matches a default-tenant stamp: NULL or empty string. + /// Legacy rows may use either; is ''. + /// + public static ParameterizedQuery IsNullOrEmpty(this ParameterizedQuery query, string field) + { + var ident = query.QuoteIdent(field); + query.Sql.AppendLine($"and ({ident} is null or {ident} = '')"); + return query; + } + /// /// Appends an IS NOT NULL clause to the query. /// @@ -165,6 +179,32 @@ public static ParameterizedQuery IsNotNull(this ParameterizedQuery query, string return query; } + /// + /// Appends an AND clause matching values strictly less than the specified value, if the value is not null. + /// + /// The query. + /// The field. + /// The value. + public static ParameterizedQuery LessThan(this ParameterizedQuery query, string field, object? value) + { + if (value == null) return query; + + var identifier = query.QuoteIdent(field); + if (query.Dialect is SqliteDialect && value is DateTimeOffset) + { + // SQLite date functions compare at millisecond precision. Keep this exclusive so a precision tie waits for the next scan. + query.Sql.AppendLine($"and julianday({identifier}) < julianday(@{field})"); + } + else + { + query.Sql.AppendLine($"and {identifier} < @{field}"); + } + + query.Parameters.Add($"@{field}", value); + + return query; + } + /// /// Appends a search term for workflow definitions to the search. /// @@ -241,9 +281,26 @@ public static ParameterizedQuery StartsWith(this ParameterizedQuery query, strin if (!startsWith || value == null || string.IsNullOrWhiteSpace(value)) return query; - var searchTermLike = $"{value}%"; - query.Sql.AppendLine($"and {query.QuoteIdent(field)} like @SearchTermLike"); - query.Parameters.Add($"@{field}", searchTermLike); + var parameterName = $"@{field}StartsWith"; + var isSqlServer = query.Dialect is SqlServerDialect; + // PostgreSQL treats backslash as LIKE's default escape, so use our explicit escape for literal paths. + var needsEscaping = value.IndexOfAny(['%', '_', LikeEscapeCharacter]) >= 0 || + value.Contains('\\') || + (isSqlServer && value.Contains('[')); + var escapeClause = needsEscaping ? $" escape '{LikeEscapeCharacter}'" : string.Empty; + var escapedValue = value + .Replace("!", "!!") + .Replace("%", "!%") + .Replace("_", "!_"); + + // SQL Server also treats '[' as a LIKE pattern character; the other supported dialects do not. + if (isSqlServer) + { + escapedValue = escapedValue.Replace("[", "!["); + } + + query.Sql.AppendLine($"and {query.QuoteIdent(field)} like {parameterName}{escapeClause}"); + query.Parameters.Add(parameterName, $"{escapedValue}%"); return query; } @@ -500,4 +557,4 @@ private static string QuoteIdent(this ParameterizedQuery query, string name) => private static string BoolLit(this ParameterizedQuery query, bool value) => query.Dialect.BooleanLiteral(value); -} \ No newline at end of file +} diff --git a/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index 44b39cc14e..df4745158e 100644 --- a/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -219,6 +219,7 @@ private void ApplyFilter(ParameterizedQuery query, WorkflowInstanceFilter filter .In(nameof(WorkflowInstance.Status), filter.WorkflowStatuses?.Select(x => x.ToString())) .In(nameof(WorkflowInstance.SubStatus), filter.WorkflowSubStatuses?.Select(x => x.ToString())) .Is(nameof(WorkflowInstance.IsExecuting), filter.IsExecuting) + .LessThan(nameof(WorkflowInstance.UpdatedAt), filter.BeforeLastUpdated) .AndWorkflowInstanceSearchTerm(filter.SearchTerm); } diff --git a/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs b/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs index 03725c44a6..7e00e3e4cd 100644 --- a/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs +++ b/src/extensions/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/KeyValueStore.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Elsa.Persistence.Dapper.Extensions; using Elsa.Persistence.Dapper.Models; using Elsa.Persistence.Dapper.Modules.Runtime.Records; @@ -6,6 +7,9 @@ using Elsa.KeyValues.Entities; using Elsa.KeyValues.Models; using JetBrains.Annotations; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Npgsql; namespace Elsa.Persistence.Dapper.Modules.Runtime.Stores; @@ -16,12 +20,91 @@ namespace Elsa.Persistence.Dapper.Modules.Runtime.Stores; internal class DapperKeyValueStore(Store store) : IKeyValueStore { /// - public Task SaveAsync(SerializedKeyValuePair keyValuePair, CancellationToken cancellationToken) + public async Task SaveAsync(SerializedKeyValuePair keyValuePair, CancellationToken cancellationToken) { var record = Map(keyValuePair); - return store.SaveAsync(record, cancellationToken); + ArgumentNullException.ThrowIfNull(record.Id); + + if (await TryUpdateOwnedAsync(record, cancellationToken)) + { + return; + } + + var existing = await FindByGlobalIdAsync(record.Id, cancellationToken); + if (existing != null) + { + // An owned row may have been inserted since the first update. + if (await TryUpdateOwnedAsync(record, cancellationToken)) + { + return; + } + throw OwnershipConflict(record.Id, existing); + } + + try + { + // Add stamps the ambient tenant, ignoring ownership supplied by the caller. + await store.AddAsync(record, cancellationToken); + } + catch (DbException exception) when (IsDuplicateKey(exception)) + { + // A competing insert is safe to retry only through the tenant-scoped update. + if (await TryUpdateOwnedAsync(record, cancellationToken)) + { + return; + } + existing = await FindByGlobalIdAsync(record.Id, cancellationToken); + if (existing != null) + { + throw OwnershipConflict(record.Id, existing); + } + throw; + } + } + + private async Task TryUpdateOwnedAsync(KeyValuePairRecord record, CancellationToken cancellationToken) + { + const int maxUpdateAttempts = 5; + for (var attempt = 0; attempt < maxUpdateAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var updated = await store.UpdateAsync(record, [x => x.Value], query => query.Is(nameof(KeyValuePairRecord.Id), record.Id), cancellationToken); + if (updated > 0) + { + return true; + } + + var owned = await store.FindAsync(query => query.Is(nameof(KeyValuePairRecord.Id), record.Id), cancellationToken); + if (owned == null) + { + return false; + } + if (owned.Value == record.Value) + { + // Some providers report zero affected rows when the value is unchanged. + return true; + } + // A same-owner insert after a missed update still needs the requested value applied. + } + + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException($"Cannot save key '{record.Id}' because sustained concurrent changes prevented an update after {maxUpdateAttempts} attempts."); } + private Task FindByGlobalIdAsync(string id, CancellationToken cancellationToken) => + store.FindAsync(query => query.Is(nameof(KeyValuePairRecord.Id), id), tenantAgnostic: true, cancellationToken); + + private static InvalidOperationException OwnershipConflict(string id, KeyValuePairRecord existing) => + new($"Cannot save key '{id}' because it belongs to {(existing.TenantId == "*" ? "the tenant-agnostic ('*') scope" : "another tenant")}."); + + private static bool IsDuplicateKey(DbException exception) => exception switch + { + SqliteException { SqliteExtendedErrorCode: 1555 or 2067 } => true, + PostgresException { SqlState: PostgresErrorCodes.UniqueViolation } => true, + SqlException { Number: 2601 or 2627 } => true, + _ => false + }; + /// public async Task FindAsync(KeyValueFilter filter, CancellationToken cancellationToken) { @@ -42,12 +125,20 @@ public Task DeleteAsync(string key, CancellationToken cancellationToken) return store.DeleteAsync(query => query.Is(nameof(KeyValuePairRecord.Id), key), cancellationToken); } + /// + public async Task TryDeleteAsync(string key, CancellationToken cancellationToken = default) + { + return await store.DeleteAsync(query => query.Is(nameof(KeyValuePairRecord.Id), key), cancellationToken) > 0; + } + private void ApplyFilter(ParameterizedQuery query, KeyValueFilter filter) { - query - .Is(nameof(KeyValuePairRecord.Id), filter.Key) - .In(nameof(KeyValuePairRecord.Id), filter.Keys) - .StartsWith(nameof(KeyValuePairRecord.Id), filter.StartsWith, filter.Key); + if (filter.StartsWith) + query.StartsWith(nameof(KeyValuePairRecord.Id), true, filter.Key); + else + query.Is(nameof(KeyValuePairRecord.Id), filter.Key); + + query.In(nameof(KeyValuePairRecord.Id), filter.Keys); } private KeyValuePairRecord Map(SerializedKeyValuePair source) diff --git a/src/extensions/persistence/Elsa.Persistence.Dapper/Services/Store.cs b/src/extensions/persistence/Elsa.Persistence.Dapper/Services/Store.cs index 91d1db4793..fe0857d2d8 100644 --- a/src/extensions/persistence/Elsa.Persistence.Dapper/Services/Store.cs +++ b/src/extensions/persistence/Elsa.Persistence.Dapper/Services/Store.cs @@ -542,7 +542,11 @@ private void ApplyTenantFilter(ParameterizedQuery query, bool tenantAgnostic = f var tenant = tenantAccessor.Tenant; var tenantId = tenant?.Id; - query.Is(nameof(Record.TenantId), (object?)tenantId ?? DBNull.Value); + // Default tenant is '' in Elsa and NULL on some legacy rows (#245 / #260). + if (string.IsNullOrEmpty(tenantId)) + query.IsNullOrEmpty(nameof(Record.TenantId)); + else + query.Is(nameof(Record.TenantId), tenantId); } private void SetTenantId(T record) diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs index 19df4c4878..df858189ad 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs @@ -182,6 +182,20 @@ public async Task UpdatePartialAsync( return updateResult.ModifiedCount > 0; } + /// + /// Updates a single document matching . Tenant scope is applied unless + /// is true. This is a single filtered update. + /// + public Task UpdateOneAsync( + FilterDefinition filter, + UpdateDefinition update, + bool tenantAgnostic = false, + CancellationToken cancellationToken = default) + { + var scopedFilter = ApplyTenantScope(filter, tenantAgnostic); + return collection.UpdateOneAsync(scopedFilter, update, cancellationToken: cancellationToken); + } + /// /// Finds the document matching the specified predicate /// @@ -509,7 +523,12 @@ private FilterDefinition CreateTenantOwnedUpsertFilter(TDocument docu & Builders.Filter.Where(x => (x as Entity)!.TenantId == writerTenantId || (x as Entity)!.TenantId == Tenant.AgnosticTenantId); } - private FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic) + /// + /// ANDs the current tenant onto . No-ops when + /// is true or is not an . + /// Writes using this filter match only the ambient tenant, not tenant-agnostic ("*") rows. + /// + public FilterDefinition ApplyTenantScope(FilterDefinition filter, bool tenantAgnostic = false) { if (tenantAgnostic || !typeof(Entity).IsAssignableFrom(typeof(TDocument))) return filter; diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs index 0983fd7b1c..13a71994c2 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Features/MongoDbFeature.cs @@ -5,6 +5,7 @@ using Elsa.Persistence.MongoDb.HostedServices; using Elsa.Persistence.MongoDb.NamingStrategies; using Elsa.Persistence.MongoDb.Options; +using Elsa.Persistence.MongoDb.Serializers; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Runtime.Entities; using Microsoft.Extensions.DependencyInjection; @@ -51,6 +52,7 @@ public override void Apply() Services.TryAddScoped(); Services.AddScoped(CollectionNamingStrategy); + Services.TryAddSingleton(); RegisterClassMaps(); } diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs index 9c6f1d753f..a6b540a385 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs @@ -17,20 +17,21 @@ namespace Elsa.Persistence.MongoDb.HostedServices; /// /// This class implements and is responsible for registering serializers to handle /// specific types such as , , , , -/// , , and . +/// , (including , , +/// and ), and . /// It uses helper methods to register these serializers during the application's startup process. /// [UsedImplicitly] -public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer) : IHostedService +public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer, VariableSerializer variableSerializer) : IHostedService { public Task StartAsync(CancellationToken cancellationToken) { TryRegisterSerializer(typeof(object), new PolymorphicSerializer()); TryRegisterSerializer(typeof(Type), new TypeSerializer()); - TryRegisterSerializer(typeof(Variable), new VariableSerializer()); + TryRegisterSerializer(typeof(Variable), variableSerializer); TryRegisterSerializer(typeof(Version), new VersionSerializer()); TryRegisterSerializer(typeof(JsonElement), new JsonElementSerializer()); - TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + JsonNodeBsonConverter.RegisterSerializers(); TryRegisterSerializer(typeof(FlowScope), new FlowScopeSerializer(payloadSerializer)); return Task.CompletedTask; diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 03c1db3baa..b025fbbfad 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -11,7 +11,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using MongoDB.Driver; -using MongoDB.Driver.Linq; using Open.Linq.AsyncExtensions; namespace Elsa.Persistence.MongoDb.Modules.Management; @@ -73,12 +72,8 @@ public async ValueTask> SummarizeManyAsync(Workflo /// public async ValueTask> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder order, CancellationToken cancellationToken = default) { - var collection = mongoDbStore.GetCollection(); - var queryable = Order(Filter(collection.AsQueryable(), filter), order); - var count = queryable.LongCount(); - var mongoQueryable = queryable.Paginate(pageArgs); - var documents = await mongoQueryable.Select(ExpressionHelpers.WorkflowInstanceSummary).ToListAsync(cancellationToken); - + var count = await mongoDbStore.CountAsync(query => Filter(query, filter), cancellationToken); + var documents = await mongoDbStore.FindManyAsync(query => OrderAndPaginate(Filter(query, filter), order, pageArgs), ExpressionHelpers.WorkflowInstanceSummary, cancellationToken).ToList(); return Page.Of(documents, count); } @@ -145,8 +140,8 @@ public async Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTim /// public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false) { - var collection = mongoDbStore.GetCollection(); // Refuse every Finished row (#8419). allowFinishedCancelled is ignored. + // Single filtered update; tenant scope is applied by MongoDbStore (#228). var idFilter = Builders.Filter.Eq(x => x.Id, workflowInstanceId); var notFinished = Builders.Filter.Ne(x => x.Status, WorkflowStatus.Finished); var filter = Builders.Filter.And(idFilter, notFinished); @@ -155,7 +150,7 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, .Set(x => x.SubStatus, WorkflowSubStatus.Interrupted) .Set(x => x.IsExecuting, false); - var result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); + var result = await mongoDbStore.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); return result.MatchedCount > 0; } diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs index ccbf94b8be..0c198d26be 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Modules/Runtime/KeyValueStore.cs @@ -3,6 +3,7 @@ using Elsa.KeyValues.Models; using Elsa.Persistence.MongoDb.Common; using JetBrains.Annotations; +using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Elsa.Persistence.MongoDb.Modules.Runtime; @@ -37,6 +38,14 @@ public Task DeleteAsync(string key, CancellationToken cancellationToken) return keyValueMongoDbStore.DeleteWhereAsync(x => x.Key == key, cancellationToken); } + /// + public async Task TryDeleteAsync(string key, CancellationToken cancellationToken = default) + { + var filter = keyValueMongoDbStore.ApplyTenantScope(Builders.Filter.Eq(x => x.Key, key)); + var result = await keyValueMongoDbStore.GetCollection().DeleteOneAsync(filter, cancellationToken); + return result.DeletedCount > 0; + } + private IQueryable Filter(IQueryable queryable, KeyValueFilter filter) { return filter.Apply(queryable); diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs index 8867440e35..d904082b35 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/JsonNodeBsonSerializer.cs @@ -2,19 +2,77 @@ using MongoDB.Bson; using MongoDB.Bson.IO; using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using static MongoDB.Bson.Serialization.BsonSerializer; namespace Elsa.Persistence.MongoDb.Serializers; /// /// Serializes a . /// -public class JsonNodeBsonConverter : IBsonSerializer +public class JsonNodeBsonConverter : JsonNodeBsonConverter +{ + private static int _providerRegistered; + + /// + /// Registers serializers for and the concrete types + /// , , and . + /// Also registers a serialization provider so internal + /// implementations are looked up without falling back to a class map. + /// + public static void RegisterSerializers() + { + if (Interlocked.CompareExchange(ref _providerRegistered, 1, 0) == 0) + RegisterSerializationProvider(new JsonNodeBsonSerializationProvider()); + + TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonObject), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonArray), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(JsonValue), new JsonNodeBsonConverter()); + } +} + +/// +/// Serializes a of the specified nominal type. +/// must match the registered type so +/// BsonSerializer.LookupSerializer (used by ) +/// finds this converter for , , and . +/// +public class JsonNodeBsonConverter : IBsonSerializer where TNode : JsonNode { /// - public Type ValueType => typeof(JsonNode); + public Type ValueType => typeof(TNode); /// - public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonNode value) + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, TNode value) + { + SerializeJsonNode(context, value); + } + + /// + public TNode Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var node = DeserializeJsonNode(context); + if (node is null) + return null!; + if (node is TNode typed) + return typed; + + throw new BsonSerializationException($"Deserialized JsonNode of type '{node.GetType().FullName}' is not assignable to '{typeof(TNode).FullName}'."); + } + + /// + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) + { + SerializeJsonNode(context, (JsonNode)value); + } + + object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + return DeserializeJsonNode(context)!; + } + + private static void SerializeJsonNode(BsonSerializationContext context, JsonNode value) { if (value == null!) { @@ -67,69 +125,147 @@ public void Serialize(BsonSerializationContext context, BsonSerializationArgs ar context.Writer.WriteEndDocument(); } - public JsonNode Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + private static JsonNode? DeserializeJsonNode(BsonDeserializationContext context) + { + var reader = context.Reader; + var bsonType = reader.GetCurrentBsonType(); + + switch (bsonType) + { + case BsonType.Null: + reader.ReadNull(); + return null; + case BsonType.Document: + // A nominal JsonObject may be a legacy map whose fields resemble another node kind's envelope. + return DeserializeDocument(BsonDocumentSerializer.Instance.Deserialize(context), requireObjectEnvelope: typeof(TNode) == typeof(JsonObject)); + case BsonType.Array: + return DeserializeLegacyArray(BsonArraySerializer.Instance.Deserialize(context)); + default: + return DeserializePrimitiveJsonValue(ReadPrimitive(reader, bsonType)); + } + } + + private static JsonNode DeserializeDocument(BsonDocument document, bool requireObjectEnvelope = false) + { + if (IsTaggedJsonNode(document, requireObjectEnvelope)) + return DeserializeTagged(document); + + // Legacy class-map / dictionary format written when only JsonNode was registered: + // { "DialogId": { "type": "JsonValue", "value": "..." }, ... } + var obj = new JsonObject(); + foreach (var element in document) + obj[element.Name] = DeserializeBsonValue(element.Value); + return obj; + } + + private static bool IsTaggedJsonNode(BsonDocument document, bool requireObjectEnvelope) { - context.Reader.ReadStartDocument(); - var type = context.Reader.ReadString(); - context.Reader.ReadName(Utf8NameDecoder.Instance); + if (document.ElementCount != 2 || !document.Contains("type") || !document.Contains("value")) + return false; + + if (document["type"].BsonType != BsonType.String) + return false; + + var type = document["type"].AsString; + if (requireObjectEnvelope && type != "JsonObject") + { + return false; + } + + return type switch + { + "JsonObject" or "JsonArray" => document["value"].BsonType == BsonType.String, + "JsonValue" => true, + _ => false + }; + } + + private static JsonNode DeserializeTagged(BsonDocument document) + { + var type = document["type"].AsString; + var value = document["value"]; - JsonNode result; switch (type) { case "JsonObject": - var jsonObjectString = context.Reader.ReadString(); - result = JsonNode.Parse(jsonObjectString); - break; - case "JsonArray": - var jsonArrayString = context.Reader.ReadString(); - result = JsonNode.Parse(jsonArrayString); - break; - + return JsonNode.Parse(value.AsString)!; case "JsonValue": - var bsonType = context.Reader.GetCurrentBsonType(); - switch (bsonType) - { - case BsonType.String: - result = JsonValue.Create(context.Reader.ReadString()); - break; - case BsonType.Int32: - result = JsonValue.Create(context.Reader.ReadInt32()); - break; - case BsonType.Int64: - result = JsonValue.Create(context.Reader.ReadInt64()); - break; - case BsonType.Double: - result = JsonValue.Create(context.Reader.ReadDouble()); - break; - case BsonType.Boolean: - result = JsonValue.Create(context.Reader.ReadBoolean()); - break; - case BsonType.DateTime: - result = JsonValue.Create(context.Reader.ReadDateTime()); - break; - default: - throw new BsonSerializationException($"Unsupported BSON type: {bsonType}"); - } - break; - + return DeserializePrimitiveJsonValue(value); default: throw new BsonSerializationException($"Unsupported JsonNode type: {type}"); } + } + + private static JsonNode? DeserializeBsonValue(BsonValue value) + { + return value.BsonType switch + { + BsonType.Null => null, + BsonType.Document => DeserializeDocument(value.AsBsonDocument), + BsonType.Array => DeserializeLegacyArray(value.AsBsonArray), + _ => DeserializePrimitiveJsonValue(value) + }; + } - context.Reader.ReadEndDocument(); - return result!; + private static JsonArray DeserializeLegacyArray(BsonArray array) + { + var result = new JsonArray(); + foreach (var item in array) + result.Add(DeserializeBsonValue(item)); + return result; } - /// - public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) + private static JsonValue DeserializePrimitiveJsonValue(BsonValue value) { - Serialize(context, args, (JsonNode)value); + switch (value.BsonType) + { + case BsonType.String: + return JsonValue.Create(value.AsString)!; + case BsonType.Int32: + return JsonValue.Create(value.AsInt32)!; + case BsonType.Int64: + return JsonValue.Create(value.AsInt64)!; + case BsonType.Double: + return JsonValue.Create(value.AsDouble)!; + case BsonType.Boolean: + return JsonValue.Create(value.AsBoolean)!; + case BsonType.DateTime: + return JsonValue.Create(value.AsBsonDateTime.MillisecondsSinceEpoch)!; + default: + throw new BsonSerializationException($"Unsupported BSON type: {value.BsonType}"); + } } - object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + private static BsonValue ReadPrimitive(IBsonReader reader, BsonType bsonType) { - return Deserialize(context, args); + return bsonType switch + { + BsonType.String => reader.ReadString(), + BsonType.Int32 => reader.ReadInt32(), + BsonType.Int64 => reader.ReadInt64(), + BsonType.Double => reader.ReadDouble(), + BsonType.Boolean => reader.ReadBoolean(), + BsonType.DateTime => new BsonDateTime(reader.ReadDateTime()), + _ => throw new BsonSerializationException($"Unsupported BSON type: {bsonType}") + }; } } +/// +/// Provides for any subclass, +/// including internal implementations whose runtime type is not +/// itself. +/// +public class JsonNodeBsonSerializationProvider : IBsonSerializationProvider +{ + /// + public IBsonSerializer? GetSerializer(Type type) + { + if (type is null || !typeof(JsonNode).IsAssignableFrom(type)) + return null; + + var serializerType = typeof(JsonNodeBsonConverter<>).MakeGenericType(type); + return (IBsonSerializer)Activator.CreateInstance(serializerType)!; + } +} diff --git a/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs b/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs index f3c98fa81c..de6547949c 100644 --- a/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs +++ b/src/extensions/persistence/Elsa.Persistence.MongoDb/Serializers/VariableSerializer.cs @@ -2,6 +2,7 @@ using Elsa.Workflows; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Bson; using MongoDB.Bson.Serialization; @@ -18,10 +19,11 @@ public class VariableSerializer : IBsonSerializer /// /// Initializes a new instance of the class. /// - public VariableSerializer() + /// The DI-registered serialization type registry used to resolve storage drivers and variable types. + /// The logger used by when type resolution fails. + public VariableSerializer(ISerializationTypeRegistry serializationTypeRegistry, ILogger? logger = null) { - var serializationTypeRegistry = SerializationTypeRegistry.CreateDefault(); - _mapper = new VariableMapper(serializationTypeRegistry, NullLogger.Instance); + _mapper = new VariableMapper(serializationTypeRegistry, logger ?? NullLogger.Instance); } /// diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs index 5350ef93b7..10e68a25a0 100644 --- a/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs +++ b/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs @@ -1,20 +1,109 @@ +using Elsa.Api.Client.Resources.Features.Models; +using Elsa.Studio.Attributes; using Elsa.Studio.Contracts; using Elsa.Studio.Services; using Xunit; namespace Elsa.Studio.Core.Tests; -/// -/// stays a default interface member so third-party -/// implementations compiled against earlier Studio packages keep working. The stock service reports -/// initialized only after completes. -/// public class DefaultFeatureServiceTests { + [Fact] + public async Task InitializeFeatures_EnablesCompanionsAdvertisedByStaticCatalogNames() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var structuredLogs = new StructuredLogsDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard, structuredLogs], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Elsa.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { FullName = "Elsa.StructuredLogsDashboard" })); + + await service.InitializeFeaturesAsync(); + + Assert.True(dashboard.Initialized); + Assert.True(structuredLogs.Initialized); + } + + [Fact] + public async Task InitializeFeatures_EnablesCompanionsAdvertisedByShellFeatureNames() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = WorkflowRuntimeDashboardFeature.RemoteFeatureName })); + + await service.InitializeFeaturesAsync(); + + Assert.True(dashboard.Initialized); + } + + [Fact] + public async Task InitializeFeatures_SkipsCompanionsMissingFromCatalog() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var service = new DefaultFeatureService( + [dashboard], + new CatalogRemoteFeatureProvider(new FeatureDescriptor { FullName = "Elsa.Secrets" })); + + await service.InitializeFeaturesAsync(); + + Assert.False(dashboard.Initialized); + } + + [Fact] + public async Task InitializeFeatures_SkipsCompanionsWhenCatalogLastSegmentIsNotElsaPrefixed() + { + var dashboard = new WorkflowRuntimeDashboardFeature(); + var identity = new IdentityFeature(); + var service = new DefaultFeatureService( + [dashboard, identity], + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Acme.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Acme" }, + new FeatureDescriptor { FullName = "Acme.Identity" }, + new FeatureDescriptor { Name = "Identity", Namespace = "Acme" })); + + await service.InitializeFeaturesAsync(); + + Assert.False(dashboard.Initialized); + Assert.False(identity.Initialized); + } + + [Theory] + [InlineData("Elsa.WorkflowRuntimeDashboard", "", "", false)] + [InlineData("", "Elsa", "WorkflowRuntimeDashboard", false)] + [InlineData("Acme.WorkflowRuntimeDashboard", "", "", true)] + public async Task InitializeFeatures_RequiresExactMatchesForForeignRequests(string fullName, string featureNamespace, string name, bool expected) + { + var feature = new ForeignDashboardFeature(); + var service = new DefaultFeatureService( + [feature], + new CatalogRemoteFeatureProvider(new FeatureDescriptor { FullName = fullName, Namespace = featureNamespace, Name = name })); + + await service.InitializeFeaturesAsync(); + + Assert.Equal(expected, feature.Initialized); + } + + [Fact] + public async Task InitializeFeatures_AlwaysInitializesUngatedFeatures() + { + var local = new LocalFeature(); + var service = new DefaultFeatureService( + [local], + new CatalogRemoteFeatureProvider()); + + await service.InitializeFeaturesAsync(); + + Assert.True(local.Initialized); + } + [Fact] public async Task InitializeFeatures_ReportsItIsInitialized_ByTheTimeInitializedIsRaised() { - IFeatureService service = new DefaultFeatureService([new LocalFeature()], new EmptyRemoteFeatureProvider()); + IFeatureService service = new DefaultFeatureService([new LocalFeature()], new CatalogRemoteFeatureProvider()); var initializedWhenRaised = false; service.Initialized += () => initializedWhenRaised = service.IsInitialized; Assert.False(service.IsInitialized); @@ -25,24 +114,48 @@ public async Task InitializeFeatures_ReportsItIsInitialized_ByTheTimeInitialized Assert.True(service.IsInitialized); } - [Fact] - public void UnimplementedIsInitialized_DefaultsToFalse() + [RemoteFeature(RemoteFeatureName)] + private sealed class WorkflowRuntimeDashboardFeature : TrackingFeature { - IFeatureService service = new LegacyFeatureService(); - - Assert.False(service.IsInitialized); + public const string RemoteFeatureName = "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard"; } - private sealed class LocalFeature : IFeature + [RemoteFeature("Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard")] + private sealed class StructuredLogsDashboardFeature : TrackingFeature; + + [RemoteFeature("Elsa.Identity.ShellFeatures.Identity")] + private sealed class IdentityFeature : TrackingFeature; + + [RemoteFeature("Acme.WorkflowRuntimeDashboard")] + private sealed class ForeignDashboardFeature : TrackingFeature; + + private sealed class LocalFeature : TrackingFeature; + + private abstract class TrackingFeature : IFeature { - public ValueTask InitializeAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public bool Initialized { get; private set; } + + public ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + Initialized = true; + return ValueTask.CompletedTask; + } } - private sealed class EmptyRemoteFeatureProvider : IRemoteFeatureProvider + private sealed class CatalogRemoteFeatureProvider(params FeatureDescriptor[] features) : IRemoteFeatureProvider { - public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => Task.FromResult(false); - public Task> ListAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(features.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal))); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(features); + } + [Fact] + public void UnimplementedIsInitialized_DefaultsToFalse() + { + IFeatureService service = new LegacyFeatureService(); + + Assert.False(service.IsInitialized); } /// Simulates a third-party implementation compiled against the default interface member. diff --git a/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs b/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs index 2b79c42be3..9f79703922 100644 --- a/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs +++ b/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs @@ -40,14 +40,9 @@ public async Task InitializeFeaturesAsync(CancellationToken cancellationToken = { var remoteFeatureName = feature.GetType().GetCustomAttribute()?.Name; - if (!string.IsNullOrWhiteSpace(remoteFeatureName)) - { - // Check if the remote feature is enabled. - var remoteFeatureIsEnabled = remoteFeatures.Any(x => x.FullName == remoteFeatureName); - - if (!remoteFeatureIsEnabled) - continue; - } + if (!string.IsNullOrWhiteSpace(remoteFeatureName) && + !RemoteFeatureCatalog.Contains(remoteFeatures, remoteFeatureName)) + continue; await feature.InitializeAsync(cancellationToken); } diff --git a/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs b/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs new file mode 100644 index 0000000000..b66c1b3769 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureCatalog.cs @@ -0,0 +1,51 @@ +using Elsa.Api.Client.Resources.Features.Models; + +namespace Elsa.Studio.Services; + +/// +/// Matches Studio remote feature names against the backend installed-feature catalog. +/// +/// +/// Dynamic/shell catalogs advertise the ShellFeatures CLR name +/// (Elsa.*.ShellFeatures.{FeatureId}). Static feature catalogs advertise the +/// CShells feature id as Elsa.{FeatureId} (for example +/// Elsa.WorkflowRuntimeDashboard). Studio modules request the ShellFeatures name, +/// so both catalog forms must resolve as the same installed feature. +/// Short-name fallback requires an Elsa-qualified request and catalog entry so an unrelated +/// Acme.{FeatureId} catalog entry cannot enable an Elsa Studio module. +/// +internal static class RemoteFeatureCatalog +{ + public static bool Contains(IEnumerable catalog, string featureName) => + catalog.Any(feature => Matches(feature, featureName)); + + public static bool Matches(FeatureDescriptor feature, string featureName) + { + if (string.Equals(feature.FullName, featureName, StringComparison.Ordinal)) + { + return true; + } + + if (!featureName.StartsWith("Elsa.", StringComparison.Ordinal)) + { + return false; + } + + var requestedId = GetFeatureId(featureName); + + if (!string.IsNullOrEmpty(feature.Name) && + string.Equals(feature.Namespace, "Elsa", StringComparison.Ordinal) && + string.Equals(feature.Name, requestedId, StringComparison.Ordinal)) + { + return true; + } + + return string.Equals(feature.FullName, $"Elsa.{requestedId}", StringComparison.Ordinal); + } + + private static string GetFeatureId(string featureName) + { + var lastDot = featureName.LastIndexOf('.'); + return lastDot < 0 ? featureName : featureName[(lastDot + 1)..]; + } +} diff --git a/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs b/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs index b84fae1426..1543f90167 100644 --- a/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs +++ b/src/studio/framework/Elsa.Studio.Core/Services/RemoteFeatureProvider.cs @@ -22,7 +22,7 @@ public class RemoteFeatureProvider( public async Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) { var catalog = await GetCatalogAsync(cancellationToken); - return catalog.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal)); + return RemoteFeatureCatalog.Contains(catalog, featureName); } /// diff --git a/src/studio/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs b/src/studio/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs index ded133c1f6..4d49c46ceb 100644 --- a/src/studio/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs +++ b/src/studio/modules/Elsa.Studio.Administration.Tests/PermissionGatedActionsTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using Bunit; using Elsa.Api.Client.Shared.Models; using Elsa.Studio.Authorization; @@ -14,9 +15,11 @@ using Elsa.Studio.Secrets.Models; using Elsa.Studio.Testing; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using MudBlazor; using MudBlazor.Services; +using Refit; using Xunit; using LabelPage = Elsa.Studio.Labels.UI.Pages.Label; using LabelsPage = Elsa.Studio.Labels.UI.Pages.Labels; @@ -29,6 +32,7 @@ namespace Elsa.Studio.Administration.Tests; public sealed class PermissionGatedActionsTests : BunitContext, IAsyncLifetime { private static readonly SecretModel ApiKey = new() { Id = "1", Name = "api-key", DisplayName = "API key", TypeName = "Text", StoreName = "Database" }; + private readonly SecretsApi _secretsApi = new(); private static readonly Label Urgent = new() { Id = "urgent", Name = "Urgent" }; public PermissionGatedActionsTests() @@ -36,7 +40,7 @@ public PermissionGatedActionsTests() JSInterop.Mode = JSRuntimeMode.Loose; Services.AddMudServices(); Services.AddSingleton(new TestLocalizer()); - Services.AddSingleton(new ApiProvider()); + Services.AddSingleton(new ApiProvider(_secretsApi)); Services.AddSingleton(new DefinitionLabelsProvider()); Render(); } @@ -64,6 +68,50 @@ public void SecretsList_WithWriteAccess_ShowsCreateAndRowActions() Assert.Single(cut.FindAll("tbody .mud-menu")); } + [Theory] + [InlineData("secrets:create", true)] + [InlineData("secrets:write", false)] + [InlineData("secrets:write", true)] + public void SecretsList_CreateAccessFollowsTheBackendCapability(string grant, bool canCreate) + { + var cut = RenderPage(["secrets:view", grant], canCreateInline: canCreate); + + cut.WaitForAssertion(() => Assert.Contains(ApiKey.DisplayName, cut.Markup)); + Assert.Equal(canCreate, cut.Markup.Contains("Create Secret")); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public async Task SecretsList_DeniedCapability_HidesCreateAndRetry(HttpStatusCode status) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "https://elsa.example.test/secrets/picker"); + using var response = new HttpResponseMessage(status); + _secretsApi.PickErrors.Enqueue(await ApiException.Create(request, HttpMethod.Post, response, new RefitSettings())); + var cut = RenderPage(["secrets:*"]); + + cut.WaitForAssertion(() => Assert.Contains(ApiKey.DisplayName, cut.Markup)); + Assert.DoesNotContain("Create Secret", cut.Markup); + Assert.DoesNotContain("Retry create access check", cut.Markup); + Assert.Equal(1, _secretsApi.PickCalls); + } + + [Fact] + public async Task SecretsList_UnavailableCapability_CanRetryAndRecover() + { + _secretsApi.PickErrors.Enqueue(new HttpRequestException("The backend is unavailable.")); + var cut = RenderPage(["secrets:view", "secrets:write"]); + cut.WaitForAssertion(() => Assert.Contains("Retry create access check", cut.Markup)); + Assert.DoesNotContain("Create Secret", cut.Markup); + + await cut.FindAll("button").Single(button => button.TextContent.Contains("Retry create access check")) + .ClickAsync(new MouseEventArgs()); + + cut.WaitForAssertion(() => Assert.Contains("Create Secret", cut.Markup)); + Assert.DoesNotContain("Retry create access check", cut.Markup); + Assert.Equal(2, _secretsApi.PickCalls); + } + [Fact] public void Secret_ViewOnly_HidesEditingRevocationAndRotation() { @@ -123,11 +171,13 @@ public void Label_WithUpdateAccess_ShowsSave() } [Theory] - [InlineData(new[] { "secrets:view" }, false)] - [InlineData(new[] { "secrets:view", "secrets:write" }, true)] - public void SecretPicker_OffersInlineCreateOnlyWithWriteAccess(string[] grants, bool offered) + [InlineData(new[] { "secrets:view" }, false, false)] + [InlineData(new[] { "secrets:view", "secrets:create" }, true, true)] + [InlineData(new[] { "secrets:view", "secrets:write" }, false, false)] + [InlineData(new[] { "secrets:view", "secrets:write" }, true, true)] + public void SecretPicker_InlineCreateFollowsTheBackendCapability(string[] grants, bool canCreate, bool offered) { - var cut = RenderPage(grants); + var cut = RenderPage(grants, canCreateInline: canCreate); Assert.Equal(offered, cut.FindComponents().Any(x => x.Instance.Icon == Icons.Material.Filled.Add)); } @@ -146,16 +196,20 @@ public void DefinitionLabelsEditor_OffersRemovingAndAddingPerPermission(string[] } // The shell's page guard cascades the user's permissions to the page. - private IRenderedComponent RenderPage(string[] grants, Action>? parameters = null) where TPage : IComponent => - Render(builder => + private IRenderedComponent RenderPage(string[] grants, Action>? parameters = null, bool? canCreateInline = null) where TPage : IComponent + { + var permissions = StubPermissionService.Grants(grants); + _secretsApi.CanCreateInline = canCreateInline ?? permissions.Has("secrets", PermissionVerbs.Write); + return Render(builder => { - builder.AddCascadingValue(StubPermissionService.Grants(grants)); + builder.AddCascadingValue(permissions); parameters?.Invoke(builder); }); + } - private sealed class ApiProvider : IBackendApiClientProvider + private sealed class ApiProvider(SecretsApi secretsApi) : IBackendApiClientProvider { - private readonly object[] _apis = [new SecretsApi(), new LabelsApi()]; + private readonly object[] _apis = [secretsApi, new LabelsApi()]; public Uri Url => new("https://elsa.example.test"); @@ -165,6 +219,10 @@ private sealed class ApiProvider : IBackendApiClientProvider private sealed class SecretsApi : ISecretsApi { + public bool CanCreateInline { get; set; } + public int PickCalls { get; private set; } + public Queue PickErrors { get; } = new(); + public Task ListAsync(string? search = null, string? typeName = null, string? storeName = null, string? scope = null, SecretStatus? status = null, int? page = null, int? pageSize = null, CancellationToken cancellationToken = default) => Task.FromResult(new ListSecretsResponse { Items = [ApiKey], TotalCount = 1 }); @@ -176,8 +234,13 @@ public Task ListAsync(string? search = null, string? typeNa public Task RevokeAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task DeleteAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task TestAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task PickAsync(SecretPickerRequest request, CancellationToken cancellationToken = default) => - Task.FromResult(new SecretPickerResponse { Items = [ApiKey], CanCreateInline = true }); + public Task PickAsync(SecretPickerRequest request, CancellationToken cancellationToken = default) + { + PickCalls++; + return PickErrors.TryDequeue(out var error) + ? Task.FromException(error) + : Task.FromResult(new SecretPickerResponse { Items = [ApiKey], CanCreateInline = CanCreateInline }); + } } private sealed class DefinitionLabelsProvider : IWorkflowDefinitionLabelsProvider diff --git a/src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs b/src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs index bf38b76b4f..544ae1219a 100644 --- a/src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs +++ b/src/studio/modules/Elsa.Studio.Dashboard.Tests/DashboardWidgetRegistrationTests.cs @@ -1,3 +1,5 @@ +using Elsa.Api.Client.Resources.Features.Models; +using Elsa.Studio.Services; using Elsa.Studio.Attributes; using Elsa.Studio.Authorization; using Elsa.Studio.Contracts; @@ -199,6 +201,39 @@ public void DashboardCompanionFeatures_DeclareRemoteBackendFeatureNames() AssertRemoteFeatureName("Elsa.Diagnostics.OpenTelemetry.ShellFeatures.OpenTelemetry"); } + [Fact] + public async Task DefaultFeatureService_RegistersWidgetsWhenStaticCatalogAdvertisesShortNames() + { + var services = new ServiceCollection(); + + services + .AddDashboardModule() + .AddWorkflowsDashboardModule() + .AddStructuredLogsDashboardModule() + .AddConsoleLogsDashboardModule() + .AddOpenTelemetryDashboardModule(); + + using var serviceProvider = services.BuildServiceProvider(); + var features = serviceProvider.GetRequiredService>().Where(x => x.GetType().Namespace?.EndsWith(".Dashboard", StringComparison.Ordinal) == true).ToList(); + var registry = serviceProvider.GetRequiredService(); + var featureService = new DefaultFeatureService( + features, + new CatalogRemoteFeatureProvider( + new FeatureDescriptor { FullName = "Elsa.WorkflowRuntimeDashboard" }, + new FeatureDescriptor { FullName = "Elsa.StructuredLogsDashboard" }, + new FeatureDescriptor { FullName = "Elsa.ConsoleLogsDashboard" }, + new FeatureDescriptor { FullName = "Elsa.OpenTelemetry" })); + + await featureService.InitializeFeaturesAsync(); + + var descriptors = registry.List(); + + AssertDescriptor(descriptors, "dashboard.workflow.metrics", DashboardWidgetZones.Metrics, 100, "WorkflowInstances"); + AssertDescriptor(descriptors, "diagnostics.structured-logs", DashboardWidgetZones.DiagnosticsStatus, 100, "Diagnostics.StructuredLogs"); + AssertDescriptor(descriptors, "diagnostics.console-logs", DashboardWidgetZones.DiagnosticsStatus, 200, "Diagnostics.ConsoleLogs"); + AssertDescriptor(descriptors, "diagnostics.open-telemetry", DashboardWidgetZones.DiagnosticsStatus, 300, "OpenTelemetry.StorageDiagnostics"); + } + private sealed class TestWidget : IComponent { public void Attach(RenderHandle renderHandle) @@ -230,6 +265,15 @@ private static void AssertRemoteFeatureName(string expectedName) Assert.Equal(expectedName, attribute.Name); } + private sealed class CatalogRemoteFeatureProvider(params FeatureDescriptor[] features) : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(features.Any(feature => string.Equals(feature.FullName, featureName, StringComparison.Ordinal))); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(features); + } + private static string ReadRepositoryFile(params string[] pathSegments) { if (pathSegments.Length < 2 || pathSegments[0] != "src") diff --git a/src/studio/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj b/src/studio/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj index dbcf865b27..c9c3d8a846 100644 --- a/src/studio/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj +++ b/src/studio/modules/Elsa.Studio.Environments.Tests/Elsa.Studio.Environments.Tests.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/studio/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs b/src/studio/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs new file mode 100644 index 0000000000..7a3e9743bc --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Environments.Tests/ElsaIdentityStartupTests.cs @@ -0,0 +1,103 @@ +using Elsa.Api.Client.Resources.Features.Models; +using Elsa.Studio.Authentication.ElsaIdentity.BlazorServer.Extensions; +using Elsa.Studio.Authentication.ElsaIdentity.Contracts; +using Elsa.Studio.Authentication.ElsaIdentity.HttpMessageHandlers; +using Elsa.Studio.Contracts; +using Elsa.Studio.Core.BlazorServer.HostedServices; +using Elsa.Studio.Environments.Contracts; +using Elsa.Studio.Environments.Extensions; +using Elsa.Studio.Extensions; +using Elsa.Studio.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Elsa.Studio.Environments.Tests; + +/// +/// Covers the Host.Server ElsaIdentity path for #1062: startup must not throw, and +/// Feature init must tolerate ElsaIdentity's real token read when JS interop is unavailable. +/// +public class ElsaIdentityStartupTests +{ + [Fact] + public async Task InitializeFeatures_DoesNotThrowWhenElsaIdentityTokenReadFails() + { + // Arrange + var jwtAccessor = new ThrowingJwtAccessor(); + using var provider = BuildElsaIdentityServices(jwtAccessor, registerHostedService: false); + + // Act + await provider.GetRequiredService().InitializeFeaturesAsync(); + + // Assert + Assert.True(jwtAccessor.ReadCount > 0); + Assert.Empty(provider.GetRequiredService().Environments); + Assert.NotEmpty(provider.GetRequiredService().AppBarElements); + } + + [Fact] + public async Task RunStartupTasksHostedService_DoesNotThrowWithEnvironmentsAndElsaIdentity() + { + // Arrange + using var provider = BuildElsaIdentityServices(new ThrowingJwtAccessor(), registerHostedService: true); + var hostedService = provider.GetServices().OfType().Single(); + + // Act + var exception = await Record.ExceptionAsync(() => hostedService.StartAsync(CancellationToken.None)); + + // Assert + Assert.Null(exception); + Assert.Empty(provider.GetRequiredService().Environments); + } + + private static ServiceProvider BuildElsaIdentityServices(IJwtAccessor jwtAccessor, bool registerHostedService) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreInternal(); + services.AddElsaIdentity(); + services.Replace(ServiceDescriptor.Scoped(_ => jwtAccessor)); + services.AddSingleton(); + + var backendApiConfig = new BackendApiConfig + { + ConfigureBackendOptions = options => options.Url = new Uri("https://backend.example/"), + ConfigureHttpClientBuilder = options => options.AuthenticationHandler = typeof(ElsaIdentityAuthenticatingApiHttpMessageHandler) + }; + + services.AddRemoteBackend(backendApiConfig); + services.AddEnvironmentsModule(backendApiConfig); + services.AddTransient(); + + if (registerHostedService) + services.AddHostedService(); + + return services.BuildServiceProvider(); + } + + private sealed class EmptyRemoteFeatureProvider : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } + + private sealed class ThrowingJwtAccessor : IJwtAccessor + { + public int ReadCount { get; private set; } + + public ValueTask ReadTokenAsync(string name) + { + ReadCount++; + throw new InvalidOperationException("JavaScript interop calls cannot be issued at this time."); + } + + public ValueTask WriteTokenAsync(string name, string token) => ValueTask.CompletedTask; + + public ValueTask ClearTokenAsync(string name) => ValueTask.CompletedTask; + } +} diff --git a/src/studio/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs b/src/studio/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs deleted file mode 100644 index 8e9d62fb3c..0000000000 --- a/src/studio/modules/Elsa.Studio.Environments/Tasks/LoadEnvironmentsStartupTask.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Net; -using Elsa.Studio.Contracts; -using Elsa.Studio.Environments.Contracts; -using Microsoft.Extensions.Logging; -using Refit; - -namespace Elsa.Studio.Environments.Tasks; - -/// -/// Loads available environments from the current backend into . -/// -public class LoadEnvironmentsStartupTask( - IBackendApiClientProvider backendApiClientProvider, - IEnvironmentService environmentService, - ILogger logger) : IStartupTask -{ - /// - /// Fetches environments through and stores them. - /// - public async ValueTask LoadAsync(CancellationToken cancellationToken = default) - { - var environmentsClient = await backendApiClientProvider.GetApiAsync(cancellationToken); - var response = await environmentsClient.ListEnvironmentsAsync(cancellationToken); - environmentService.SetEnvironments(response.Environments, response.DefaultEnvironmentName); - } - - /// - public async ValueTask ExecuteAsync(CancellationToken cancellationToken = default) - { - try - { - await LoadAsync(cancellationToken); - } - catch (HttpRequestException exception) - { - // Hosted-service startup can run before the circuit has a backend connection. - logger.LogWarning(exception, "Could not reach the environments API during startup."); - } - catch (ApiException exception) when (exception.StatusCode is HttpStatusCode.NotFound - or HttpStatusCode.Unauthorized - or HttpStatusCode.Forbidden) - { - logger.LogWarning("Environments API returned {StatusCode} during startup; environments will load after login if the API is available.", (int)exception.StatusCode); - } - } -} diff --git a/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs index 9ac4795eca..c5c0ad0640 100644 --- a/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs +++ b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/OpenIdConnectBlazorServerSignOutTests.cs @@ -41,6 +41,7 @@ public sealed class OpenIdConnectBlazorServerSignOutTests private static readonly RendererInfo Circuit = new("Server", isInteractive: true); private readonly OpenIdConnectConfiguration _provider = new() { EndSessionEndpoint = EndSessionEndpoint }; private readonly StubHttpContextAccessor _httpContextAccessor = new(); + private readonly HostedNavigationManager _navigation = new("https://studio.example/"); private readonly PersistedAntiforgeryState _persistedAntiforgery = new(); private readonly Dictionary _browserCookies = []; private readonly WebApplication _studio; @@ -69,6 +70,7 @@ public OpenIdConnectBlazorServerSignOutTests() Services.AddSingleton(_studio.Services.GetRequiredService()); Services.AddSingleton(_httpContextAccessor); Services.AddSingleton(_persistedAntiforgery); + Services.AddSingleton(_navigation); } private CookieAuthenticationOptions SessionCookieOptions => @@ -131,15 +133,19 @@ public void AnonymousUser_IsNotIssuedAnAntiforgeryCookie() public void LogoutFormAction_PreservesTheHostedPathBase(string baseUri, string expected) => Assert.Equal(expected, HostedAuthenticationPaths.LogoutFormAction(baseUri)); - [Fact] - public void SignOutForm_PostsToTheHostedLogoutPath() + [Theory] + [InlineData("https://studio.example/", "/authentication/logout")] + [InlineData("https://studio.example/studio/", "/studio/authentication/logout")] + [InlineData("https://studio.example/elsa/studio/", "/elsa/studio/authentication/logout")] + public void SignOutForm_PreservesTheHostedPathBase(string baseUri, string expectedAction) { + _navigation.SetBaseUri(baseUri); SignIn(); var menu = RenderWhileHandling(BrowserRequest(HttpMethods.Get, "/"), Prerender); var form = ((IHtmlButtonElement)FindInOpenMenu(menu, "button[type=submit]")).Form!; - Assert.Equal("/authentication/logout", form.GetAttribute("action")); + Assert.Equal(expectedAction, form.GetAttribute("action")); } [Fact] @@ -257,6 +263,19 @@ private static DefaultHttpContext StartedResponse(DefaultHttpContext request) return request; } + private sealed class HostedNavigationManager : NavigationManager + { + public HostedNavigationManager(string baseUri) => Initialize(baseUri, baseUri); + + public void SetBaseUri(string baseUri) + { + BaseUri = baseUri; + Uri = baseUri; + } + + protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotSupportedException(); + } + private DefaultHttpContext FormRequest(string method, string path, Dictionary fields) { var request = BrowserRequest(method, path); diff --git a/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs new file mode 100644 index 0000000000..93a44f8e95 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Compatibility/StudioAuthenticationProviderRegistrationTests.cs @@ -0,0 +1,63 @@ +using Elsa.Studio.Authentication.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ServerOpenIdConnect = Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions.ServiceCollectionExtensions; +using WasmOpenIdConnect = Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions.ServiceCollectionExtensions; + +namespace Elsa.Studio.ExternalAuthentication.Tests.Compatibility; + +public class StudioAuthenticationProviderRegistrationTests +{ + [Fact] + public void AddStudioAuthenticationProviderRegistration_DoesNotThrowOnFirstCall() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + services.AddStudioAuthenticationProviderRegistration(StudioAuthenticationProvider.OpenIdConnect)); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Equal( + StudioAuthenticationProvider.OpenIdConnect, + Assert.Single(provider.GetServices()).Provider); + } + + [Fact] + public void AddOpenIdConnectAuth_Server_DoesNotThrowOnRegistration() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + ServerOpenIdConnect.AddOpenIdConnectAuth(services, options => + { + options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; + options.ClientId = "any-client-id"; + })); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Contains( + provider.GetServices(), + registration => registration.Provider == StudioAuthenticationProvider.OpenIdConnect); + } + + [Fact] + public void AddOpenIdConnectAuth_Wasm_DoesNotThrowOnRegistration() + { + var services = new ServiceCollection(); + + var exception = Record.Exception(() => + WasmOpenIdConnect.AddOpenIdConnectAuth(services, options => + { + options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; + options.ClientId = "any-client-id"; + })); + + Assert.Null(exception); + using var provider = services.BuildServiceProvider(); + Assert.Contains( + provider.GetServices(), + registration => registration.Provider == StudioAuthenticationProvider.OpenIdConnect); + } +} diff --git a/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs index 0159b60c34..e739535c9b 100644 --- a/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs +++ b/src/studio/modules/Elsa.Studio.ExternalAuthentication.Tests/Operations/RemoteFeatureProviderTests.cs @@ -1,13 +1,14 @@ -using System.Net; -using System.Security.Claims; using Elsa.Api.Client.Resources.Features.Contracts; using Elsa.Api.Client.Resources.Features.Models; using Elsa.Api.Client.Shared.Models; using Elsa.Studio.Contracts; using Elsa.Studio.Services; -using Elsa.Studio.Testing; using Microsoft.AspNetCore.Components.Authorization; +using Refit; +using System.Net; +using System.Security.Claims; using Xunit; +using Elsa.Studio.Testing; namespace Elsa.Studio.ExternalAuthentication.Tests.Operations; @@ -25,6 +26,54 @@ public async Task FeatureChecks_ReuseTheInstalledFeatureCatalog() Assert.Equal(0, api.GetCalls); } + [Theory] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Elsa.WorkflowRuntimeDashboard")] + [InlineData("Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard", "Elsa.StructuredLogsDashboard")] + [InlineData("Elsa.Diagnostics.StructuredLogs.ShellFeatures.StructuredLogs", "Elsa.StructuredLogs")] + [InlineData("Elsa.ExternalAuthentication.ShellFeatures.ExternalAuthentication", "Elsa.ExternalAuthentication")] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")] + public async Task FeatureChecks_AcceptStaticCatalogNames(string requestedName, string catalogFullName) + { + var api = new FeaturesApi + { + Features = new([new FeatureDescriptor { FullName = catalogFullName }], 1) + }; + var provider = new RemoteFeatureProvider(new BackendApiClientProvider(api)); + + Assert.True(await provider.IsEnabledAsync(requestedName)); + Assert.False(await provider.IsEnabledAsync("Elsa.MissingFeature")); + } + + [Fact] + public async Task FeatureChecks_AcceptCatalogNameAsFeatureId() + { + var api = new FeaturesApi + { + Features = new([new FeatureDescriptor { Name = "WorkflowRuntimeDashboard", Namespace = "Elsa" }], 1) + }; + var provider = new RemoteFeatureProvider(new BackendApiClientProvider(api)); + + Assert.True(await provider.IsEnabledAsync("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard")); + } + + [Theory] + [InlineData("Elsa.Workflows.Runtime.Dashboard.ShellFeatures.WorkflowRuntimeDashboard", "Acme.WorkflowRuntimeDashboard", "WorkflowRuntimeDashboard")] + [InlineData("Elsa.Identity.ShellFeatures.Identity", "Acme.Identity", "Identity")] + public async Task FeatureChecks_RejectNonElsaLastSegmentCollisions(string requestedName, string foreignFullName, string foreignName) + { + var fullNameCatalog = new FeaturesApi + { + Features = new([new FeatureDescriptor { FullName = foreignFullName }], 1) + }; + var namespaceCatalog = new FeaturesApi + { + Features = new([new FeatureDescriptor { Name = foreignName, Namespace = "Acme" }], 1) + }; + + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(fullNameCatalog)).IsEnabledAsync(requestedName)); + Assert.False(await new RemoteFeatureProvider(new BackendApiClientProvider(namespaceCatalog)).IsEnabledAsync(requestedName)); + } + [Fact] public async Task AnonymousFeatureChecks_DoNotProbeTheProtectedBackend() { @@ -174,6 +223,7 @@ private sealed class FeaturesApi : IFeaturesApi [new FeatureDescriptor { FullName = "Elsa.ExternalAuthentication" }], 1); + public ListResponse Features { get; set; } = InstalledFeatures; public Queue>>> Responses { get; } = new(); public int GetCalls { get; private set; } public int ListCalls { get; private set; } @@ -190,7 +240,7 @@ public Task> ListAsync(CancellationToken cancell if (Responses.TryDequeue(out var response)) return response(cancellationToken); - return Task.FromResult(InstalledFeatures); + return Task.FromResult(Features); } } diff --git a/src/studio/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor b/src/studio/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor index 15f495b2b9..86701eba7f 100644 --- a/src/studio/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor +++ b/src/studio/modules/Elsa.Studio.Secrets/Components/SecretPicker.razor @@ -14,12 +14,9 @@ @if (!Disabled && AllowInlineCreate && _canCreateInline) { - @* PermissionView, unlike the page's cascaded permissions, also works when the picker renders in a dialog. *@ - - - - - + + + } diff --git a/src/studio/modules/Elsa.Studio.Secrets/Pages/Secrets.razor b/src/studio/modules/Elsa.Studio.Secrets/Pages/Secrets.razor index b47a400f5f..0b6ccee001 100644 --- a/src/studio/modules/Elsa.Studio.Secrets/Pages/Secrets.razor +++ b/src/studio/modules/Elsa.Studio.Secrets/Pages/Secrets.razor @@ -35,7 +35,7 @@ Class="d-flex flex-row justify-end align-end gap-3 flex-wrap pl-2 pr-6 py-6 mt-0" Style="height: 36.5px; width: 400px" ValueChanged="@OnSearchChanged" /> - @if (CanWrite && _canCreate) + @if (_canCreate) { Create Secret } @@ -103,7 +103,7 @@ [CascadingParameter] private UserPermissions Permissions { get; set; } = UserPermissions.Unknown; - // Create, rotate and revoke all require secrets:write. The create button also waits for the backend capability probe. + // Rotate and revoke require secrets:write; create access comes from the backend capability. private bool CanWrite => Permissions.Has(SecretsPermissions.Secrets, PermissionVerbs.Write); private bool CanTest => Permissions.Has(SecretsPermissions.Secrets, SecretVerbs.Test); private bool CanDelete => Permissions.Has(SecretsPermissions.Secrets, PermissionVerbs.Delete); diff --git a/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs b/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs new file mode 100644 index 0000000000..ae73b07115 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionDetailsTests.cs @@ -0,0 +1,150 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Resources.Resilience.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; +using Elsa.Studio.Testing; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Elsa.Studio.Workflows.Domain.Contracts; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ActivityExecutionDetailsTests : BunitContext, IAsyncLifetime +{ + private readonly ActivityExecutionServiceStub _executionService = new(); + + public ActivityExecutionDetailsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(_executionService); + Services.AddSingleton(); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public void RendersStateOutcomesAndOutputForTheSelectedExecution() + { + var record = new ActivityExecutionRecord + { + Id = "exec-1", + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow, + ActivityState = new Dictionary + { + ["Text"] = "hello", + ["_internal"] = "hidden" + }, + Payload = new Dictionary + { + ["Outcomes"] = "Done" + }, + Outputs = new Dictionary + { + ["Result"] = "ok" + } + }; + + var cut = Render(parameters => parameters + .Add(details => details.ActivityExecution, record)); + + cut.WaitForAssertion(() => + { + Assert.Contains("State", cut.Markup); + Assert.Contains("Text", cut.Markup); + Assert.Contains("hello", cut.Markup); + Assert.DoesNotContain("_internal", cut.Markup); + Assert.Contains("Outcomes", cut.Markup); + Assert.Contains("Done", cut.Markup); + Assert.Contains("Output", cut.Markup); + Assert.Contains("Result", cut.Markup); + Assert.Contains("ok", cut.Markup); + }); + } + + [Fact] + public void RendersRetryAttemptsWhenTheExecutionHasRetries() + { + _executionService.Retries = new PagedListResponse + { + Items = + [ + new RetryAttemptRecord + { + AttemptNumber = 1, + RetryDelay = TimeSpan.FromSeconds(1), + Details = new Dictionary { ["Reason"] = "timeout" } + } + ], + TotalCount = 1 + }; + + var record = new ActivityExecutionRecord + { + Id = "exec-retry", + ActivityId = "HttpRequest1", + ActivityNodeId = "Workflow1:HttpRequest1", + ActivityType = "Elsa.HttpRequest", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + + var cut = Render(parameters => parameters + .Add(details => details.ActivityExecution, record)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Retry Attempts", cut.Markup); + Assert.Contains("timeout", cut.Markup); + }); + } + + private sealed class ActivityExecutionServiceStub : IActivityExecutionService + { + public PagedListResponse Retries { get; set; } = new(); + + public Task GetReportAsync(string workflowInstanceId, System.Text.Json.Nodes.JsonObject containerActivity, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task GetAsync(string id, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + Task.FromResult(Retries); + } + + private sealed class ClipboardStub : IClipboard + { + public Task CopyText(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } +} diff --git a/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs b/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs new file mode 100644 index 0000000000..84e65c99e0 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows.Tests/ActivityExecutionsTabTests.cs @@ -0,0 +1,70 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; +using Elsa.Studio.Testing; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ActivityExecutionsTabTests : BunitContext, IAsyncLifetime +{ + public ActivityExecutionsTabTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public async Task ClickingAnExecutionRow_RaisesExecutionSelectedWithThatRecordId() + { + string? selectedId = null; + var summaries = new List + { + CreateSummary("exec-a"), + CreateSummary("exec-b") + }; + + var cut = Render(parameters => parameters + .Add(tab => tab.ActivityExecutionSummaries, summaries) + .Add(tab => tab.VisiblePaneHeight, 400) + .Add(tab => tab.ExecutionSelected, id => selectedId = id)); + + var rows = cut.FindAll("tbody tr"); + Assert.Equal(2, rows.Count); + + await rows[1].ClickAsync(); + + cut.WaitForAssertion(() => Assert.Equal("exec-b", selectedId)); + } + + private static ActivityExecutionRecordSummary CreateSummary(string id) + { + return new ActivityExecutionRecordSummary + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + } + + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } +} diff --git a/src/studio/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs b/src/studio/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs new file mode 100644 index 0000000000..f452af5544 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows.Tests/ExecutionDetailsDrawerRenderTests.cs @@ -0,0 +1,204 @@ +using Bunit; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.Localization; +using Elsa.Studio.Localization.Time; +using Elsa.Studio.Testing; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Elsa.Studio.Workflows.Domain.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +public sealed class ExecutionDetailsDrawerRenderTests : BunitContext, IAsyncLifetime +{ + private readonly ActivityExecutionServiceStub _executionService = new(); + + public ExecutionDetailsDrawerRenderTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(_executionService); + Render(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public void SelectingAnExecution_MountsTheOpenDrawerWithThatExecutionsDetails() + { + var record = CreateRecord("exec-1", "hello", "Done", "ok"); + _executionService.Record = record; + + var cut = Render(parameters => parameters + .Add(host => host.Summaries, [CreateSummary("exec-1"), CreateSummary("exec-2")])); + + Assert.Empty(cut.FindAll("aside.execution-details-drawer")); + + cut.FindAll("tbody tr")[0].Click(); + + cut.WaitForAssertion(() => + { + var drawer = cut.Find("aside.execution-details-drawer"); + Assert.Contains("mud-drawer--open", drawer.ClassList); + Assert.Contains("mud-drawer-temporary", drawer.ClassList); + Assert.Contains("Execution Details", cut.Markup); + Assert.Contains("hello", cut.Markup); + Assert.Contains("Done", cut.Markup); + Assert.Contains("ok", cut.Markup); + }); + } + + [Fact] + public void HeaderClose_DismissesTheDrawer() + { + var cut = Render(parameters => parameters + .Add(host => host.InitiallyOpen, true) + .Add(host => host.ActivityExecution, CreateRecord("exec-1", "hello", "Done", "ok"))); + + Assert.NotEmpty(cut.FindAll("aside.execution-details-drawer")); + + cut.Find("button[aria-label='Close']").Click(); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("aside.execution-details-drawer"))); + } + + [Fact] + public async Task OverlayDismissal_UnmountsTheDrawer() + { + var cut = Render(parameters => parameters + .Add(host => host.InitiallyOpen, true) + .Add(host => host.ActivityExecution, CreateRecord("exec-1", "hello", "Done", "ok"))); + + var drawer = cut.FindComponent(); + await cut.InvokeAsync(() => drawer.Instance.OpenChanged.InvokeAsync(false)); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("aside.execution-details-drawer"))); + } + + private static ActivityExecutionRecord CreateRecord(string id, string text, string outcome, string output) => new() + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow, + ActivityState = new Dictionary { ["Text"] = text }, + Payload = new Dictionary { ["Outcomes"] = outcome }, + Outputs = new Dictionary { ["Result"] = output } + }; + + private static ActivityExecutionRecordSummary CreateSummary(string id) => new() + { + Id = id, + ActivityId = "WriteLine1", + ActivityNodeId = "Workflow1:WriteLine1", + ActivityType = "Elsa.WriteLine", + Status = ActivityStatus.Completed, + StartedAt = DateTimeOffset.UtcNow + }; + + /// + /// Owns drawer open-state the same way WorkflowInstanceViewer does. + /// + private sealed class DrawerHost : ComponentBase + { + [Parameter] public bool InitiallyOpen { get; set; } + [Parameter] public ActivityExecutionRecord? ActivityExecution { get; set; } + + private bool _open; + + protected override void OnInitialized() => _open = InitiallyOpen; + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Open", _open); + builder.AddAttribute(2, "ActivityExecution", ActivityExecution); + builder.AddAttribute(3, "OpenChanged", EventCallback.Factory.Create(this, OnOpenChanged)); + builder.CloseComponent(); + } + + private void OnOpenChanged(bool open) => _open = open; + } + + /// + /// Same wiring as WorkflowInstanceViewer: Executions-tab row click loads the record and opens the production drawer. + /// The full viewer also mounts Journal, the designer, and SignalR, which is not practical in bUnit. + /// + private sealed class ExecutionSelectionHost : ComponentBase + { + [Parameter] public ICollection Summaries { get; set; } = []; + [Inject] private IActivityExecutionService ActivityExecutionService { get; set; } = null!; + + private bool _open; + private ActivityExecutionRecord? _record; + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, "ActivityExecutionSummaries", Summaries); + builder.AddAttribute(2, "VisiblePaneHeight", 400); + builder.AddAttribute(3, "ExecutionSelected", EventCallback.Factory.Create(this, OnExecutionSelected)); + builder.CloseComponent(); + + builder.OpenComponent(4); + builder.AddAttribute(5, "Open", _open); + builder.AddAttribute(6, "ActivityExecution", _record); + builder.AddAttribute(7, "OpenChanged", EventCallback.Factory.Create(this, OnOpenChanged)); + builder.CloseComponent(); + } + + private async Task OnExecutionSelected(string executionId) + { + _record = await ActivityExecutionService.GetAsync(executionId); + _open = _record != null; + } + + private void OnOpenChanged(bool open) => _open = open; + } + + private sealed class ActivityExecutionServiceStub : IActivityExecutionService + { + public ActivityExecutionRecord? Record { get; set; } + + public Task GetReportAsync(string workflowInstanceId, System.Text.Json.Nodes.JsonObject containerActivity, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => + Task.FromResult(Enumerable.Empty()); + + public Task GetAsync(string id, CancellationToken cancellationToken = default) => + Task.FromResult(id == Record?.Id ? Record : null); + + public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => + Task.FromResult(new PagedListResponse()); + } + + private sealed class ClipboardStub : IClipboard + { + public Task CopyText(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class TestTimeFormatter : ITimeFormatter + { + public string Format(DateTimeOffset? value, string format = "G", string emptyString = "") => + value?.ToString(format) ?? emptyString; + } +} diff --git a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor new file mode 100644 index 0000000000..0fb0469faf --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor @@ -0,0 +1,28 @@ +@inherits StudioComponentBase +@inject ILocalizer Localizer + +@* Mount only while open so MudBlazor 9 applies mud-drawer--open on first render. + Persistent + @bind-Open left the parent flag true while the drawer stayed closed/off-canvas. *@ +@if (Open) +{ + + + @Localizer["Execution Details"] + + + + +
+ +
+
+} diff --git a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs new file mode 100644 index 0000000000..a34db80ff2 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/ActivityExecutionDetailsDrawer.razor.cs @@ -0,0 +1,31 @@ +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; + +/// +/// Right-side drawer that shows one activity execution's State, Outcomes, Output, and Retry Attempts. +/// +public partial class ActivityExecutionDetailsDrawer +{ + /// + /// Whether the drawer is open. + /// + [Parameter] public bool Open { get; set; } + + /// + /// Raised when the drawer requests an open-state change (header close or overlay). + /// + [Parameter] public EventCallback OpenChanged { get; set; } + + /// + /// The execution record to display. + /// + [Parameter] public ActivityExecutionRecord? ActivityExecution { get; set; } + + private Task Close() => OnOpenChanged(false); + + private Task OnOpenChanged(bool open) => OpenChanged.HasDelegate + ? OpenChanged.InvokeAsync(open) + : Task.CompletedTask; +} diff --git a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor index 1773f48dba..f0be9a4a2d 100644 --- a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor +++ b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor @@ -2,8 +2,8 @@ @using Orientation = Radzen.Orientation @inject ILocalizer Localizer - -
+ +
@@ -36,20 +36,7 @@
- - - @Localizer["Execution Details"] - - - - -
- -
-
+
diff --git a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs index 0cf3c57252..bd713f5c73 100644 --- a/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs +++ b/src/studio/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/WorkflowInstanceViewer.razor.cs @@ -177,8 +177,7 @@ private async Task OnCallStackEntrySelected(ActivityExecutionRecord record) _programmaticallySelectedNodeId = record.ActivityNodeId; _selectedActivityNodeId = record.ActivityNodeId; _selectedExecutionForDrawer = record; - _isExecutionDetailsDrawerOpen = true; - StateHasChanged(); // Open drawer and highlight immediately. + OpenExecutionDetailsDrawer(); await _workspace.SelectActivityByIdAsync(record.ActivityId, record.ActivityNodeId); } @@ -225,11 +224,21 @@ private async Task OnActivityExecutionSelected(string? executionId) _selectedActivityExecutionRecordId = record.Id; _selectedActivityNodeId = record.ActivityNodeId; _selectedExecutionForDrawer = record; - _isExecutionDetailsDrawerOpen = true; - StateHasChanged(); + OpenExecutionDetailsDrawer(); } } + private void OpenExecutionDetailsDrawer() + { + _isExecutionDetailsDrawerOpen = true; + StateHasChanged(); + } + + private void OnExecutionDetailsDrawerOpenChanged(bool open) + { + _isExecutionDetailsDrawerOpen = open; + } + async ValueTask IAsyncDisposable.DisposeAsync() { await DisposeObserverAsync(); diff --git a/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs b/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs index 77808ab821..dd11d79bfb 100644 --- a/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs +++ b/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/DapperWorkflowInstanceStoreTests.cs @@ -4,6 +4,7 @@ using Elsa.Persistence.Dapper.Modules.Management.Stores; using Elsa.Persistence.Dapper.Services; using Elsa.Workflows; +using Elsa.Workflows.Management.Filters; using Microsoft.Data.Sqlite; using NSubstitute; @@ -11,6 +12,8 @@ namespace Elsa.Dapper.UnitTests; public sealed class DapperWorkflowInstanceStoreTests : IDisposable { + private static readonly DateTimeOffset Cutoff = new(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-instances-{Guid.NewGuid():N}.db"); private readonly string _connectionString; private readonly DapperWorkflowInstanceStore _store; @@ -44,10 +47,65 @@ FinishedAt text null ); """); + // Seeded through parameters rather than literals so the driver writes the timestamps in + // exactly the format it later binds them in for comparison. + Insert(connection, "stale-executing", Cutoff.AddMinutes(-5), isExecuting: true); + Insert(connection, "stale-idle", Cutoff.AddMinutes(-1), isExecuting: false); + Insert(connection, "at-cutoff", Cutoff, isExecuting: true); + Insert(connection, "fresh-executing", Cutoff.AddMinutes(4), isExecuting: true); + var store = new Store(connectionProvider, _tenantAccessor, "WorkflowInstances"); _store = new DapperWorkflowInstanceStore(store, Substitute.For()); } + [Fact] + public async Task FindManyIdsAsync_WithBeforeLastUpdated_ExcludesInstancesUpdatedAtOrAfterTheCutoff() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter { BeforeLastUpdated = Cutoff }); + + Assert.Equal(["stale-executing", "stale-idle"], ids.Order()); + } + + /// + /// Mirrors the filter used by RestartInterruptedWorkflowsTask. An instance that is executing right + /// now must not be reported as interrupted, or it gets restarted from the beginning mid-flight. + /// + [Fact] + public async Task FindManyIdsAsync_WithBeforeLastUpdatedAndIsExecuting_ExcludesInstancesThatAreStillActive() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter + { + IsExecuting = true, + BeforeLastUpdated = Cutoff + }); + + Assert.Equal(["stale-executing"], ids); + } + + [Fact] + public async Task FindManyIdsAsync_WithoutBeforeLastUpdated_ReturnsAllInstances() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var ids = await _store.FindManyIdsAsync(new WorkflowInstanceFilter()); + + Assert.Equal(["at-cutoff", "fresh-executing", "stale-executing", "stale-idle"], ids.Order()); + } + + [Fact] + public async Task CountAsync_WithBeforeLastUpdated_CountsOnlyInstancesUpdatedBeforeTheCutoff() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var count = await _store.CountAsync(new WorkflowInstanceFilter { BeforeLastUpdated = Cutoff }); + + Assert.Equal(2, count); + } + [Fact(DisplayName = "TryMarkInterruptedAsync marks a Running instance as Running+Interrupted")] public async Task TryMarkInterruptedAsync_MarksRunningInstance() { diff --git a/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs b/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs new file mode 100644 index 0000000000..2adfb29bd5 --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs @@ -0,0 +1,78 @@ +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Models; + +namespace Elsa.Dapper.UnitTests; + +public class ParameterizedQueryBuilderExtensionsTests +{ + [Fact(DisplayName = "LessThan appends an exclusive comparison and binds @{field}")] + public void LessThan_WithValue_AppendsExclusiveComparison() + { + var cutoff = new DateTimeOffset(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); + var query = new ParameterizedQuery(new SqliteDialect()) + .From("WorkflowInstances") + .LessThan("UpdatedAt", cutoff); + + var sql = query.Sql.ToString(); + + Assert.Contains("and julianday(UpdatedAt) < julianday(@UpdatedAt)", sql, StringComparison.Ordinal); + Assert.DoesNotContain("<=", sql, StringComparison.Ordinal); + Assert.Equal(cutoff, query.Parameters.Get("UpdatedAt")); + } + + [Fact(DisplayName = "LessThan is a no-op when the value is null")] + public void LessThan_WithNullValue_PreservesOtherFilters() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("WorkflowInstances") + .Is("IsExecuting", true) + .LessThan("UpdatedAt", null); + + var sql = query.Sql.ToString(); + + Assert.Contains("and IsExecuting = @IsExecuting", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UpdatedAt", sql, StringComparison.Ordinal); + Assert.DoesNotContain("UpdatedAt", query.Parameters.ParameterNames); + Assert.True(query.Parameters.Get("IsExecuting")); + } + + [Fact(DisplayName = "StartsWith binds @{field}StartsWith so it does not collide with Is(@{field})")] + public void StartsWith_BindsFieldPrefixedParameter() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .StartsWith("Id", true, "app:"); + + var sql = query.Sql.ToString(); + + Assert.Contains("and Id like @IdStartsWith", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@SearchTermLike", sql, StringComparison.Ordinal); + Assert.Equal("app:%", query.Parameters.Get("IdStartsWith")); + Assert.DoesNotContain("Id", query.Parameters.ParameterNames.Where(name => name == "Id")); + } + + [Fact(DisplayName = "IsNullOrEmpty matches NULL or empty string for the default tenant")] + public void IsNullOrEmpty_MatchesNullOrEmptyTenantId() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .IsNullOrEmpty("TenantId"); + + Assert.Contains("and (TenantId is null or TenantId = '')", query.Sql.ToString(), StringComparison.Ordinal); + } + + [Fact(DisplayName = "StartsWith quotes the identifier on PostgreSQL and binds @{field}StartsWith")] + public void StartsWith_PostgreSql_QuotesIdentifierAndBindsFieldPrefixedParameter() + { + var query = new ParameterizedQuery(new PostgreSqlDialect()) + .From("KeyValues") + .StartsWith("Id", true, "app:"); + + var sql = query.Sql.ToString(); + + Assert.Contains("and \"Id\" like @IdStartsWith", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@SearchTermLike", sql, StringComparison.Ordinal); + Assert.Equal("app:%", query.Parameters.Get("IdStartsWith")); + } +} diff --git a/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs new file mode 100644 index 0000000000..9adf84ee4a --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/JsonNodeBsonConverterTests.cs @@ -0,0 +1,311 @@ +using System.Text.Json.Nodes; +using Elsa.Extensions; +using Elsa.Persistence.MongoDb.Serializers; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.UnitTests; + +public class JsonNodeBsonConverterTests +{ + static JsonNodeBsonConverterTests() + { + JsonNodeBsonConverter.RegisterSerializers(); + } + + [Fact(DisplayName = "JsonObject round-trips through the tagged BSON format")] + public void JsonObject_RoundTrips_ThroughTaggedFormat() + { + var original = JsonNode.Parse("""{"DialogId":"abc","Count":42,"Nested":{"Ok":true}}""")!.AsObject(); + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.Equal("abc", restored["DialogId"]!.GetValue()); + Assert.Equal(42, restored["Count"]!.GetValue()); + Assert.True(restored["Nested"]!["Ok"]!.GetValue()); + } + + [Fact(DisplayName = "JsonArray round-trips through the tagged BSON format")] + public void JsonArray_RoundTrips_ThroughTaggedFormat() + { + var original = JsonNode.Parse("""["a",1,true,{"k":"v"}]""")!.AsArray(); + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.Equal(4, restored.Count); + Assert.Equal("a", restored[0]!.GetValue()); + Assert.Equal(1, restored[1]!.GetValue()); + Assert.True(restored[2]!.GetValue()); + Assert.Equal("v", restored[3]!["k"]!.GetValue()); + } + + [Fact(DisplayName = "JsonValue string round-trips through the tagged BSON format")] + public void JsonValue_String_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create("hello")!); + + [Fact(DisplayName = "JsonValue int round-trips through the tagged BSON format")] + public void JsonValue_Int_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(42)!); + + [Fact(DisplayName = "JsonValue bool round-trips through the tagged BSON format")] + public void JsonValue_Bool_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(true)!); + + [Fact(DisplayName = "JsonValue double round-trips through the tagged BSON format")] + public void JsonValue_Double_RoundTrips_ThroughTaggedFormat() => + AssertJsonValueRoundTrip(JsonValue.Create(1.5)!); + + [Fact(DisplayName = "JsonNode serializer round-trips a JsonObject")] + public void JsonNode_RoundTrips_JsonObject() + { + var original = JsonNode.Parse("""{"payload":{"DialogId":"xyz"}}""")!; + var serializer = new JsonNodeBsonConverter(); + + var restored = RoundTrip(serializer, original); + + Assert.IsType(restored); + Assert.Equal("xyz", restored["payload"]!["DialogId"]!.GetValue()); + } + + [Fact(DisplayName = "Null JsonNode serializes as BSON null")] + public void NullJsonNode_RoundTripsAsNull() + { + var serializer = new JsonNodeBsonConverter(); + var document = new BsonDocument(); + + using (var writer = new BsonDocumentWriter(document)) + { + writer.WriteStartDocument(); + writer.WriteName("value"); + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), null!); + writer.WriteEndDocument(); + } + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Null(restored); + } + + [Fact(DisplayName = "Legacy class-map JsonObject document deserializes without a parameterless constructor")] + public void Deserialize_LegacyMapFormat_RestoresJsonObject() + { + var legacy = new BsonDocument + { + { "DialogId", new BsonDocument { { "type", "JsonValue" }, { "value", "abc" } } }, + { "Count", new BsonDocument { { "type", "JsonValue" }, { "value", 7 } } }, + { "Nested", new BsonDocument { { "type", "JsonObject" }, { "value", """{"Ok":true}""" } } } + }; + var serializer = new JsonNodeBsonConverter(); + + using var reader = new BsonDocumentReader(legacy); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Equal("abc", restored["DialogId"]!.GetValue()); + Assert.Equal(7, restored["Count"]!.GetValue()); + Assert.True(restored["Nested"]!["Ok"]!.GetValue()); + } + + [Fact(DisplayName = "Legacy class-map JsonArray document deserializes")] + public void Deserialize_LegacyArrayFormat_RestoresJsonArray() + { + var legacy = new BsonArray + { + new BsonDocument { { "type", "JsonValue" }, { "value", "a" } }, + new BsonDocument { { "type", "JsonValue" }, { "value", 1 } } + }; + var serializer = new JsonNodeBsonConverter(); + var document = new BsonDocument { { "value", legacy } }; + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Equal(2, restored.Count); + Assert.Equal("a", restored[0]!.GetValue()); + Assert.Equal(1, restored[1]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonObject without the parameterless-constructor failure")] + public void PolymorphicSerializer_JsonObject_RoundTrips() + { + var original = JsonNode.Parse("""{"DialogId":"abc","Enabled":true}""")!.AsObject(); + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var obj = Assert.IsType(restored); + Assert.Equal("abc", obj["DialogId"]!.GetValue()); + Assert.True(obj["Enabled"]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonArray")] + public void PolymorphicSerializer_JsonArray_RoundTrips() + { + var original = JsonNode.Parse("""[1,"x"]""")!.AsArray(); + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var array = Assert.IsType(restored); + Assert.Equal(1, array[0]!.GetValue()); + Assert.Equal("x", array[1]!.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer round-trips JsonValue")] + public void PolymorphicSerializer_JsonValue_RoundTrips() + { + var original = JsonValue.Create("hello")!; + var serializer = new PolymorphicSerializer(); + + var restored = RoundTripObject(serializer, original); + + var value = Assert.IsAssignableFrom(restored); + Assert.Equal("hello", value.GetValue()); + } + + [Fact(DisplayName = "PolymorphicSerializer deserializes a pre-fix JsonObject written in the legacy map format")] + public void PolymorphicSerializer_DeserializesLegacyJsonObjectMap() + { + var typeName = typeof(JsonObject).GetSimpleAssemblyQualifiedName(); + var document = new BsonDocument + { + { "$type", typeName }, + { + "$value", new BsonDocument + { + { "DialogId", new BsonDocument { { "type", "JsonValue" }, { "value", "from-legacy" } } }, + { "Count", new BsonDocument { { "type", "JsonValue" }, { "value", 3 } } } + } + } + }; + var serializer = new PolymorphicSerializer(); + + using var reader = new BsonDocumentReader(document); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + var obj = Assert.IsType(restored); + Assert.Equal("from-legacy", obj["DialogId"]!.GetValue()); + Assert.Equal(3, obj["Count"]!.GetValue()); + } + + [Theory(DisplayName = "A legacy JsonObject with incompatible envelope fields preserves its object type")] + [InlineData("JsonValue", "legacy-value")] + [InlineData("JsonArray", "[]")] + public void Deserialize_LegacyIncompatibleEnvelopeShapedJsonObject_PreservesFields(string type, string value) + { + var legacy = new BsonDocument { { "type", type }, { "value", value } }; + + var restored = DeserializeDocument(new JsonNodeBsonConverter(), legacy); + + Assert.Equal(2, restored.Count); + Assert.Equal(type, restored["type"]!.GetValue()); + Assert.Equal(value, restored["value"]!.GetValue()); + } + + [Theory(DisplayName = "Polymorphic JsonObject dispatch preserves a legacy incompatible-envelope-shaped object")] + [InlineData("JsonValue", "legacy-value")] + [InlineData("JsonArray", "[]")] + public void PolymorphicSerializer_LegacyIncompatibleEnvelopeShapedJsonObject_PreservesObjectType(string type, string value) + { + var document = new BsonDocument + { + { "$type", typeof(JsonObject).GetSimpleAssemblyQualifiedName() }, + { "$value", new BsonDocument { { "type", type }, { "value", value } } } + }; + + var restored = DeserializeDocument(new PolymorphicSerializer(), document); + + var obj = Assert.IsType(restored); + Assert.Equal(type, obj["type"]!.GetValue()); + Assert.Equal(value, obj["value"]!.GetValue()); + } + + [Fact(DisplayName = "JsonNode dispatch still restores a genuine scalar envelope as a JsonValue")] + public void JsonNode_RoundTrips_GenuineScalarEnvelope() + { + JsonNode original = JsonValue.Create("scalar-value")!; + + var restored = RoundTrip(new JsonNodeBsonConverter(), original); + + var value = Assert.IsAssignableFrom(restored); + Assert.Equal("scalar-value", value.GetValue()); + } + + [Fact(DisplayName = "A type/value document that is not a two-field string envelope deserializes as a map")] + public void Deserialize_AmbiguousTypeValueDocument_IsMapNotEnvelope() + { + // {"type":"JsonObject","value":"{\"a\":1}"} with ElementCount == 2 and a BSON string + // value is a valid tagged envelope (covered by the round-trip tests). The false-positive + // risk is a class-map / user document that merely has type/value keys: + // extra elements, or value as a nested BSON document instead of a JSON string. + var extraElements = new BsonDocument + { + { "type", "JsonObject" }, + { "value", """{"a":1}""" }, + { "DialogId", "abc" } + }; + var nestedDocumentValue = new BsonDocument + { + { "type", "JsonObject" }, + { "value", new BsonDocument { { "a", 1 } } } + }; + var serializer = new JsonNodeBsonConverter(); + + var withExtra = DeserializeDocument(serializer, extraElements); + Assert.Equal("JsonObject", withExtra["type"]!.GetValue()); + Assert.Equal("""{"a":1}""", withExtra["value"]!.GetValue()); + Assert.Equal("abc", withExtra["DialogId"]!.GetValue()); + + var withNestedValue = DeserializeDocument(serializer, nestedDocumentValue); + Assert.Equal("JsonObject", withNestedValue["type"]!.GetValue()); + Assert.Equal(1, withNestedValue["value"]!["a"]!.GetValue()); + } + + [Fact(DisplayName = "LookupSerializer returns the JsonNode converter for JsonObject, JsonArray, and JsonValue")] + public void LookupSerializer_ConcreteJsonNodeTypes_UseJsonNodeConverter() + { + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonObject))); + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonArray))); + Assert.IsAssignableFrom>(BsonSerializer.LookupSerializer(typeof(JsonValue))); + Assert.IsAssignableFrom(BsonSerializer.LookupSerializer(typeof(JsonNode))); + } + + private static void AssertJsonValueRoundTrip(JsonValue original) + { + var restored = RoundTrip(new JsonNodeBsonConverter(), original); + Assert.Equal(original.ToJsonString(), restored.ToJsonString()); + } + + private static T DeserializeDocument(IBsonSerializer serializer, BsonDocument document) + { + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static T RoundTrip(IBsonSerializer serializer, T value) where T : JsonNode + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), value); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static object RoundTripObject(PolymorphicSerializer serializer, object value) + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), value); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } +} diff --git a/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs new file mode 100644 index 0000000000..a880b56cd7 --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTryDeleteTests.cs @@ -0,0 +1,156 @@ +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Runtime; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +/// +/// Atomic TryDeleteAsync against a shared Mongo collection (#260). +/// +public sealed class MongoKeyValueStoreTryDeleteTests : IAsyncLifetime +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenants = new(); + private MongoClient? _client; + private IMongoCollection _collection = null!; + private MongoDbStore _mongoStore = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-trydelete-{Guid.NewGuid():N}"); + _collection = database.GetCollection("key_values"); + _mongoStore = new MongoDbStore(_collection, _tenants); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + try + { + _client?.Dispose(); + } + finally + { + await _container.DisposeAsync(); + } + } + + [Fact(DisplayName = "#260: two Mongo stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + using var tenant = _tenants.PushContext(Tenant.Default); + IKeyValueStore nodeA = new MongoKeyValueStore(_mongoStore); + IKeyValueStore nodeB = new MongoKeyValueStore(_mongoStore); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: the default find-then-delete lets both racers win")] + public async Task DefaultTryDeleteAsync_TwoNodes_BothReturnTrue() + { + using var tenant = _tenants.PushContext(Tenant.Default); + var inner = new MongoKeyValueStore(_mongoStore); + await inner.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + var dim = new BarrierFindThenDelete(inner); + + var results = await Task.WhenAll(dim.TryDeleteAsync(LegacyKey), dim.TryDeleteAsync(LegacyKey)); + + Assert.Equal(2, results.Count(won => won)); + Assert.Null(await inner.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + using var tenant = _tenants.PushContext(Tenant.Default); + IKeyValueStore store = new MongoKeyValueStore(_mongoStore); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#260: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + await _collection.InsertOneAsync(new SerializedKeyValuePair + { + Key = LegacyKey, + SerializedValue = "legacy-null", + TenantId = null + }); + + using var tenant = _tenants.PushContext(Tenant.Default); + IKeyValueStore store = new MongoKeyValueStore(_mongoStore); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } + + /// + /// The core default TryDeleteAsync (find, then delete, always true if found). + /// Both Finds complete before either Delete so the race is deterministic. + /// + private sealed class BarrierFindThenDelete(IKeyValueStore inner) + { + private readonly TaskCompletionSource _bothFound = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _finds; + + public async Task TryDeleteAsync(string key) + { + var found = await inner.FindAsync(new KeyValueFilter { Key = key }, CancellationToken.None); + if (Interlocked.Increment(ref _finds) == 2) + _bothFound.TrySetResult(); + await _bothFound.Task; + if (found is null) + return false; + await inner.DeleteAsync(key, CancellationToken.None); + return true; + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs new file mode 100644 index 0000000000..ce457f43d8 --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/MongoWorkflowInstanceStoreTenantIsolationTests.cs @@ -0,0 +1,169 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.MongoDb.Common; +using Elsa.Persistence.MongoDb.Modules.Management; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.State; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using NSubstitute; +using Testcontainers.MongoDb; + +namespace Elsa.MongoDb.UnitTests; + +public sealed class MongoWorkflowInstanceStoreTenantIsolationTests : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:7.0.24").Build(); + private readonly TestTenantAccessor _tenantAccessor = new(); + private MongoClient? _client; + private MongoWorkflowInstanceStore _store = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + + _client = new MongoClient(_container.GetConnectionString()); + var database = _client.GetDatabase($"elsa-instance-tenants-{Guid.NewGuid():N}"); + var collection = database.GetCollection("workflow_instances"); + var mongoDbStore = new MongoDbStore(collection, _tenantAccessor); + _store = new MongoWorkflowInstanceStore(mongoDbStore, Substitute.For>()); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + try + { + _client?.Dispose(); + } + finally + { + await _container.DisposeAsync(); + } + } + + [Fact] + public async Task TenantB_CannotMarkOrSummarize_TenantARunningRow() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + await _store.AddAsync(Instance("running-a", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + using (var tenantB = _tenantAccessor.PushContext(new Tenant { Id = "tenant-b" })) + await _store.AddAsync(Instance("running-b", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + // Act + bool marked; + Page summaries; + using (var tenantB = _tenantAccessor.PushContext(new Tenant { Id = "tenant-b" })) + { + marked = await _store.TryMarkInterruptedAsync("running-a"); + summaries = await _store.SummarizeManyAsync( + new WorkflowInstanceFilter(), + PageArgs.FromPage(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Ascending)); + } + + // Assert + Assert.False(marked); + Assert.Equal(["running-b"], summaries.Items.Select(x => x.Id)); + + using var verifyTenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var stored = await _store.FindAsync(new WorkflowInstanceFilter { Id = "running-a" }); + Assert.NotNull(stored); + Assert.Equal(WorkflowStatus.Running, stored.Status); + Assert.Equal(WorkflowSubStatus.Executing, stored.SubStatus); + Assert.True(stored.IsExecuting); + } + + [Fact] + public async Task SameTenant_CanMarkAndSummarize_OwnRunningRow() + { + // Arrange + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + await _store.AddAsync(Instance("running-a", WorkflowStatus.Running, WorkflowSubStatus.Executing, isExecuting: true)); + + // Act + bool marked; + Page summaries; + using (var tenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + { + marked = await _store.TryMarkInterruptedAsync("running-a"); + summaries = await _store.SummarizeManyAsync( + new WorkflowInstanceFilter(), + PageArgs.FromPage(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Ascending)); + } + + // Assert + Assert.True(marked); + var summary = Assert.Single(summaries.Items); + Assert.Equal("running-a", summary.Id); + Assert.Equal(WorkflowStatus.Running, summary.Status); + Assert.Equal(WorkflowSubStatus.Interrupted, summary.SubStatus); + + using var verifyTenantA = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var stored = await _store.FindAsync(new WorkflowInstanceFilter { Id = "running-a" }); + Assert.NotNull(stored); + Assert.Equal(WorkflowStatus.Running, stored.Status); + Assert.Equal(WorkflowSubStatus.Interrupted, stored.SubStatus); + Assert.False(stored.IsExecuting); + } + + private static WorkflowInstance Instance(string id, WorkflowStatus status, WorkflowSubStatus subStatus, bool isExecuting) + { + var now = DateTimeOffset.UtcNow; + return new WorkflowInstance + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Version = 1, + WorkflowState = new WorkflowState + { + Id = id, + DefinitionId = "definition-1", + DefinitionVersionId = "definition-1:1", + Status = status, + SubStatus = subStatus, + IsExecuting = isExecuting, + CreatedAt = now, + UpdatedAt = now + }, + Status = status, + SubStatus = subStatus, + IsExecuting = isExecuting, + CreatedAt = now, + UpdatedAt = now + }; + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs new file mode 100644 index 0000000000..62d3051950 --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.MongoDb.UnitTests/VariableSerializerTests.cs @@ -0,0 +1,110 @@ +using Elsa.Common.Serialization; +using Elsa.Extensions; +using Elsa.Persistence.MongoDb.Serializers; +using Elsa.Workflows; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Elsa.Workflows.Services; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.UnitTests; + +public class VariableSerializerTests +{ + [Fact(DisplayName = "VariableSerializer preserves StorageDriverType when using the populated serialization type registry")] + public void Serialize_WithPopulatedRegistry_RoundTripsWorkflowInstanceStorageDriver() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + var variable = CreateDialogIdVariable(); + + var restored = RoundTrip(serializer, variable); + + Assert.Equal(variable.Id, restored.Id); + Assert.Equal(variable.Name, restored.Name); + Assert.Equal("abc", restored.Value); + Assert.Equal(typeof(WorkflowInstanceStorageDriver), restored.StorageDriverType); + Assert.IsType>(restored); + } + + [Fact(DisplayName = "VariableSerializer resolves the 3.7 storageDriverTypeName assembly-qualified name through the populated registry")] + public void Deserialize_WithLegacyWorkflowInstanceStorageDriverTypeName_RestoresStorageDriverType() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + var legacyTypeName = typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName(); + var model = new VariableModel("dialogIdVariable", "DialogId", "String", "abc", legacyTypeName); + + var restored = DeserializeModel(serializer, model); + + Assert.Equal(typeof(WorkflowInstanceStorageDriver), restored.StorageDriverType); + Assert.Equal("DialogId", restored.Name); + Assert.Equal("abc", restored.Value); + } + + [Fact(DisplayName = "VariableSerializer loses StorageDriverType against an empty default registry (the 3.8.0 regression)")] + public void Serialize_WithEmptyDefaultRegistry_DropsStorageDriverType() + { + var serializer = CreateSerializer(SerializationTypeRegistry.CreateDefault()); + var variable = CreateDialogIdVariable(); + + var restored = RoundTrip(serializer, variable); + + Assert.Null(restored.StorageDriverType); + } + + [Fact(DisplayName = "VariableSerializer serializes null variables as BSON null")] + public void Serialize_NullVariable_RoundTripsAsNull() + { + var serializer = CreateSerializer(CreateWorkflowsRegistry()); + + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + { + writer.WriteStartDocument(); + writer.WriteName("value"); + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), null!); + writer.WriteEndDocument(); + } + + using var reader = new BsonDocumentReader(document); + reader.ReadStartDocument(); + reader.ReadName("value"); + var restored = serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + + Assert.Null(restored); + } + + private static VariableSerializer CreateSerializer(ISerializationTypeRegistry registry) => new(registry); + + private static ISerializationTypeRegistry CreateWorkflowsRegistry() + { + var options = new SerializationTypeOptions(); + options.AddTypeAliasWithLegacyName(nameof(WorkflowInstanceStorageDriver)); + options.AddTypeAliasWithLegacyName(nameof(MemoryStorageDriver)); + return new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options)); + } + + private static Variable CreateDialogIdVariable() => + new("DialogId", "abc", "dialogIdVariable") + { + StorageDriverType = typeof(WorkflowInstanceStorageDriver) + }; + + private static Variable RoundTrip(VariableSerializer serializer, Variable variable) + { + var document = new BsonDocument(); + using (var writer = new BsonDocumentWriter(document)) + serializer.Serialize(BsonSerializationContext.CreateRoot(writer), variable); + + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } + + private static Variable DeserializeModel(VariableSerializer serializer, VariableModel model) + { + var document = model.ToBsonDocument(); + using var reader = new BsonDocumentReader(document); + return serializer.Deserialize(BsonDeserializationContext.CreateRoot(reader)); + } +} diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreOwnershipTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreOwnershipTests.cs new file mode 100644 index 0000000000..478070461f --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreOwnershipTests.cs @@ -0,0 +1,332 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.Persistence.Dapper.Contracts; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using Testcontainers.PostgreSql; + +namespace Elsa.Persistence.Dapper.UnitTests; + +public sealed class DapperKeyValueStoreOwnershipSqliteTests : DapperKeyValueStoreOwnershipTests +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"key-owner-{Guid.NewGuid():N}.db"); + protected override IDbConnectionProvider Provider => new SqliteDbConnectionProvider($"Data Source={_path};Pooling=false;Default Timeout=30"); + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + File.Delete(_path); + } +} + +public sealed class DapperKeyValueStoreOwnershipPostgreSqlTests(PostgresKeyOwnershipFixture fixture) + : DapperKeyValueStoreOwnershipTests, IClassFixture +{ + protected override IDbConnectionProvider Provider => new PostgreSqlDbConnectionProvider(fixture.ConnectionString); +} + +public sealed class PostgresKeyOwnershipFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder().WithImage("postgres:16").Build(); + public string ConnectionString => _container.GetConnectionString(); + public Task InitializeAsync() => _container.StartAsync(); + public async Task DisposeAsync() => await _container.DisposeAsync(); +} + +public abstract class DapperKeyValueStoreOwnershipTests : IAsyncLifetime +{ + private readonly string _table = $"KeyValues_{Guid.NewGuid():N}"; + protected abstract IDbConnectionProvider Provider { get; } + + public async Task InitializeAsync() + { + using var connection = Provider.GetConnection(); + await connection.ExecuteAsync($"""create table "{_table}" ("Id" text not null primary key, "TenantId" text null, "Value" text null check ("Value" <> 'forbidden'))"""); + } + + public virtual async Task DisposeAsync() + { + using var connection = Provider.GetConnection(); + await connection.ExecuteAsync($"""drop table if exists "{_table}" """); + } + + [Fact] + public async Task SameOwner_CanInsertUpdateAndSaveUnchangedValue() + { + var store = CreateStore("alpha"); + await Save(store, "first"); + await Save(store, "second"); + await Save(store, "second"); + await AssertRow("alpha", "second"); + } + + [Theory] + [InlineData("alpha")] + [InlineData("*")] + public async Task ForeignOwner_SaveCannotReplaceOwnerOrValue(string owner) + { + await Seed(owner, "original"); + var error = await Assert.ThrowsAsync(() => Save(CreateStore("beta"), "replacement")); + Assert.Contains("tenant", error.Message, StringComparison.OrdinalIgnoreCase); + await AssertRow(owner, "original"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task DefaultTenant_SavePreservesLegacyOwnerRepresentation(string? owner) + { + await Seed(owner, "original"); + await Save(CreateStore(""), "replacement"); + await AssertRow(owner, "replacement"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForgedIncomingTenant_CannotRehomeKey(bool existing) + { + if (existing) + { + await Seed("alpha", "original"); + } + await Save(CreateStore("alpha"), "replacement", "forged"); + await AssertRow("alpha", "replacement"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompetingInserts_PreserveOneConsistentOwner(bool sameOwner) + { + var barrier = new InsertBarrier(); + var provider = new InterceptingProvider(Provider, barrier.BeforeInsert); + var first = Save(CreateStore("alpha", provider), "first"); + var secondOwner = sameOwner ? "alpha" : "beta"; + var second = Save(CreateStore(secondOwner, provider), "second"); + var results = await Task.WhenAll(Capture(first), Capture(second)); + var row = await ReadRow(); + if (sameOwner) + { + Assert.All(results, Assert.Null); + Assert.Equal("alpha", row.TenantId); + Assert.Contains(row.Value, new[] { "first", "second" }); + } + else + { + Assert.Single(results, x => x == null); + Assert.IsType(Assert.Single(results, x => x != null)); + var firstWon = results[0] == null; + Assert.Equal(firstWon ? "alpha" : "beta", row.TenantId); + Assert.Equal(firstWon ? "first" : "second", row.Value); + } + } + + [Fact] + public async Task ZeroAffectedRows_UnchangedValueIsSuccessful() + { + await Seed("alpha", "requested"); + var provider = new InterceptingProvider(Provider, afterUpdate: _ => Task.FromResult(0)); + await Save(CreateStore("alpha", provider), "requested"); + await AssertRow("alpha", "requested"); + } + + [Fact] + public async Task MissedUpdate_ConcurrentOwnedInsertWithDifferentValueIsUpdated() + { + var updates = 0; + var provider = new InterceptingProvider(Provider, afterUpdate: async count => + { + if (Interlocked.Increment(ref updates) == 1 && count == 0) + { + await Seed("alpha", "concurrent"); + } + return count; + }); + await Save(CreateStore("alpha", provider), "requested"); + Assert.True(updates >= 2); + await AssertRow("alpha", "requested"); + } + + [Fact] + public async Task SustainedOwnedDeleteAndReinsert_SaveTerminatesWithoutCancellation() + { + var updates = 0; + var provider = new InterceptingProvider(Provider, beforeUpdate: async () => + { + // Keep the regression bounded before the fix without leaking a background save. + if (++updates > 12) + { + throw new ChurnSafetyException(); + } + using var connection = Provider.GetConnection(); + await connection.ExecuteAsync($"""delete from "{_table}" where "Id" = 'shared' """); + }, afterUpdate: async count => + { + Assert.Equal(0, count); + await Seed("alpha", "concurrent"); + return count; + }); + + var error = await Assert.ThrowsAsync(() => Save(CreateStore("alpha", provider), "requested")); + + Assert.Contains("shared", error.Message, StringComparison.Ordinal); + Assert.Contains("sustained concurrent changes", error.Message, StringComparison.OrdinalIgnoreCase); + await AssertRow("alpha", "concurrent"); + } + + private sealed class ChurnSafetyException : Exception; + + [Fact] + public async Task UnrelatedInsertFailure_Propagates() + { + await Assert.ThrowsAnyAsync(() => Save(CreateStore("alpha"), "forbidden")); + using var connection = Provider.GetConnection(); + Assert.Equal(0, await connection.ExecuteScalarAsync($"""select count(*) from "{_table}" """)); + } + + [Fact] + public async Task NullKey_DoesNotUpdateOtherOwnedRows() + { + await Seed("alpha", "original"); + var pair = new SerializedKeyValuePair { Id = null!, SerializedValue = "replacement" }; + await Assert.ThrowsAsync(() => CreateStore("alpha").SaveAsync(pair, CancellationToken.None)); + await AssertRow("alpha", "original"); + } + + private IKeyValueStore CreateStore(string tenantId, IDbConnectionProvider? provider = null) => + new DapperKeyValueStore(new Store(provider ?? Provider, new FixedTenantAccessor(tenantId), _table)); + + private static Task Save(IKeyValueStore store, string value, string? incomingTenant = null) => + store.SaveAsync(new SerializedKeyValuePair { Id = "shared", SerializedValue = value, TenantId = incomingTenant }, CancellationToken.None); + + private async Task Seed(string? tenantId, string value) + { + using var connection = Provider.GetConnection(); + await connection.ExecuteAsync($"""insert into "{_table}" ("Id", "TenantId", "Value") values ('shared', @tenantId, @value)""", new { tenantId, value }); + } + + private async Task ReadRow() + { + using var connection = Provider.GetConnection(); + return await connection.QuerySingleAsync($"""select * from "{_table}" """); + } + + private async Task AssertRow(string? owner, string value) + { + var row = await ReadRow(); + Assert.Equal(owner, row.TenantId); + Assert.Equal(value, row.Value); + } + + private static async Task Capture(Task save) + { + try + { + await save; + return null; + } + catch (Exception error) + { + return error; + } + } + + private sealed class FixedTenantAccessor(string tenantId) : ITenantAccessor + { + public string TenantId => tenantId; + public Tenant Tenant { get; } = new() { Id = tenantId }; + public IDisposable PushContext(Tenant? tenant) => throw new NotSupportedException(); + } + + private sealed class InsertBarrier + { + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrivals; + public async Task BeforeInsert() + { + if (Interlocked.Increment(ref _arrivals) == 2) + { + _ready.SetResult(); + } + await _ready.Task.WaitAsync(TimeSpan.FromSeconds(30)); + } + } + + // Intercept actual database commands to reproduce insert races and providers reporting zero changed rows. + private sealed class InterceptingProvider(IDbConnectionProvider inner, Func? beforeInsert = null, Func>? afterUpdate = null, Func? beforeUpdate = null) : IDbConnectionProvider + { + public string GetConnectionString() => inner.GetConnectionString(); + public ISqlDialect Dialect => inner.Dialect; + public IDbConnection GetConnection() => new InterceptingConnection((DbConnection)inner.GetConnection(), beforeInsert, afterUpdate, beforeUpdate); + } + + private sealed class InterceptingConnection(DbConnection inner, Func? beforeInsert, Func>? afterUpdate, Func? beforeUpdate) : DbConnection + { + [AllowNull] public override string ConnectionString { get => inner.ConnectionString; set => inner.ConnectionString = value; } + public override string Database => inner.Database; + public override string DataSource => inner.DataSource; + public override string ServerVersion => inner.ServerVersion; + public override ConnectionState State => inner.State; + public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); + public override void Close() => inner.Close(); + public override void Open() => inner.Open(); + public override Task OpenAsync(CancellationToken cancellationToken) => inner.OpenAsync(cancellationToken); + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => inner.BeginTransaction(isolationLevel); + protected override DbCommand CreateDbCommand() => new InterceptingCommand(inner.CreateCommand(), beforeInsert, afterUpdate, beforeUpdate); + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + base.Dispose(disposing); + } + } + + private sealed class InterceptingCommand(DbCommand inner, Func? beforeInsert, Func>? afterUpdate, Func? beforeUpdate) : DbCommand + { + [AllowNull] public override string CommandText { get => inner.CommandText; set => inner.CommandText = value; } + public override int CommandTimeout { get => inner.CommandTimeout; set => inner.CommandTimeout = value; } + public override CommandType CommandType { get => inner.CommandType; set => inner.CommandType = value; } + public override bool DesignTimeVisible { get => inner.DesignTimeVisible; set => inner.DesignTimeVisible = value; } + public override UpdateRowSource UpdatedRowSource { get => inner.UpdatedRowSource; set => inner.UpdatedRowSource = value; } + protected override DbConnection? DbConnection { get => inner.Connection; set => inner.Connection = value; } + protected override DbTransaction? DbTransaction { get => inner.Transaction; set => inner.Transaction = value; } + protected override DbParameterCollection DbParameterCollection => inner.Parameters; + public override void Cancel() => inner.Cancel(); + public override int ExecuteNonQuery() => inner.ExecuteNonQuery(); + public override object? ExecuteScalar() => inner.ExecuteScalar(); + public override void Prepare() => inner.Prepare(); + protected override DbParameter CreateDbParameter() => inner.CreateParameter(); + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => inner.ExecuteReader(behavior); + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => inner.ExecuteReaderAsync(behavior, cancellationToken); + public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + if (beforeInsert != null && CommandText.StartsWith("insert", StringComparison.OrdinalIgnoreCase)) + { + await beforeInsert(); + } + if (beforeUpdate != null && CommandText.StartsWith("update", StringComparison.OrdinalIgnoreCase)) + { + await beforeUpdate(); + } + var count = await inner.ExecuteNonQueryAsync(cancellationToken); + return afterUpdate != null && CommandText.StartsWith("update", StringComparison.OrdinalIgnoreCase) ? await afterUpdate(count) : count; + } + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs new file mode 100644 index 0000000000..65638efa4d --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeletePostgreSqlTests.cs @@ -0,0 +1,135 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Same #260 TryDelete cases as SQLite, on a fresh migration-built PostgreSQL via Testcontainers. +/// +public sealed class DapperKeyValueStoreTryDeletePostgreSqlTests : IAsyncLifetime +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16") + .WithDatabase("elsa") + .WithUsername("elsa") + .WithPassword("elsa") + .Build(); + private readonly TestTenantAccessor _tenants = new(); + private string _connectionString = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + [Fact(DisplayName = "#260 PG: two Dapper stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var nodeA = CreateStore(); + var nodeB = CreateStore(); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260 PG: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#245 / #260 PG: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + MigrateUp(); + using (var connection = new NpgsqlConnection(_connectionString)) + { + connection.Execute( + """insert into "KeyValues" ("Id", "TenantId", "Value") values (@Id, null, @Value)""", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + private IKeyValueStore CreateStore() => + new DapperKeyValueStore(new Store(new PostgreSqlDbConnectionProvider(_connectionString), _tenants, "KeyValues")); + + private void MigrateUp() + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService().MigrateUp(); + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs new file mode 100644 index 0000000000..9043ed52bd --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValueStoreTryDeleteTests.cs @@ -0,0 +1,191 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using FluentMigrator.Runner; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Atomic TryDeleteAsync on a migration-built SQLite DB (#260). +/// PostgreSQL coverage is in . +/// +public sealed class DapperKeyValueStoreTryDeleteTests : IDisposable +{ + private const string LegacyKey = "elsa.quiescence.pause.default"; + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-trydelete-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; + private readonly TestTenantAccessor _tenants = new(); + + public DapperKeyValueStoreTryDeleteTests() + { + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + } + + [Fact(DisplayName = "#260: two Dapper stores racing TryDeleteAsync: exactly one returns true")] + public async Task TryDeleteAsync_TwoNodes_ExactlyOneReturnsTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var nodeA = CreateStore(); + var nodeB = CreateStore(); + await nodeA.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + + var results = await Task.WhenAll(nodeA.TryDeleteAsync(LegacyKey), nodeB.TryDeleteAsync(LegacyKey)); + + Assert.Equal(1, results.Count(won => won)); + Assert.Equal(1, results.Count(won => !won)); + Assert.Null(await nodeA.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: the default find-then-delete lets both racers win")] + public async Task DefaultTryDeleteAsync_TwoNodes_BothReturnTrue() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var inner = CreateStore(); + await inner.SaveAsync(Pair(LegacyKey, "legacy-maintenance"), CancellationToken.None); + var dim = new BarrierFindThenDelete(inner); + + var results = await Task.WhenAll(dim.TryDeleteAsync(LegacyKey), dim.TryDeleteAsync(LegacyKey)); + + Assert.Equal(2, results.Count(won => won)); + Assert.Null(await inner.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#260: TryDeleteAsync returns false when the key is missing")] + public async Task TryDeleteAsync_NotFound_ReturnsFalse() + { + MigrateUp(); + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + Assert.False(await store.TryDeleteAsync(LegacyKey)); + Assert.False(await store.TryDeleteAsync(LegacyKey)); + } + + [Fact(DisplayName = "#245 / #260: a NULL TenantId legacy row is found and TryDeleted by the default tenant")] + public async Task DefaultTenant_FindsAndTryDeletesNullTenantIdRow() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute( + "insert into KeyValues (Id, TenantId, Value) values (@Id, null, @Value)", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + var store = CreateStore(); + + var found = await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None); + Assert.Equal("legacy-null", found?.SerializedValue); + Assert.True(await store.TryDeleteAsync(LegacyKey)); + Assert.Null(await store.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + } + + [Fact(DisplayName = "#245 / #260: a named tenant cannot TryDelete a NULL TenantId default-tenant row")] + public async Task NamedTenant_DoesNotTryDeleteNullTenantIdRow() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute( + "insert into KeyValues (Id, TenantId, Value) values (@Id, null, @Value)", + new { Id = LegacyKey, Value = "legacy-null" }); + } + + using (var named = _tenants.PushContext(new Tenant { Id = "tenant-a" })) + { + var namedStore = CreateStore(); + Assert.Null(await namedStore.FindAsync(new KeyValueFilter { Key = LegacyKey }, CancellationToken.None)); + Assert.False(await namedStore.TryDeleteAsync(LegacyKey)); + } + + using var tenant = _tenants.PushContext(Tenant.Default); + Assert.True(TableHasKey(LegacyKey)); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private IKeyValueStore CreateStore() + { + var connection = new SqliteDbConnectionProvider(_connectionString); + return new DapperKeyValueStore(new Store(connection, _tenants, "KeyValues")); + } + + private void MigrateUp() + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSQLite() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + scope.ServiceProvider.GetRequiredService().MigrateUp(); + } + + private bool TableHasKey(string key) + { + using var connection = new SqliteConnection(_connectionString); + return connection.ExecuteScalar("select count(*) from KeyValues where Id = @key", new { key }) == 1; + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } + + /// + /// The core default TryDeleteAsync (find, then delete, always true if found). + /// Both Finds complete before either Delete so the race is deterministic. + /// + private sealed class BarrierFindThenDelete(IKeyValueStore inner) + { + private readonly TaskCompletionSource _bothFound = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _finds; + + public async Task TryDeleteAsync(string key) + { + var found = await inner.FindAsync(new KeyValueFilter { Key = key }, CancellationToken.None); + if (Interlocked.Increment(ref _finds) == 2) + _bothFound.TrySetResult(); + await _bothFound.Task; + if (found is null) + return false; + await inner.DeleteAsync(key, CancellationToken.None); + return true; + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs new file mode 100644 index 0000000000..d1f8e5f76b --- /dev/null +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperKeyValuesAndBookmarkQueueTests.cs @@ -0,0 +1,287 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Extensions; +using Elsa.KeyValues.Contracts; +using Elsa.KeyValues.Entities; +using Elsa.KeyValues.Models; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Models; +using Elsa.Workflows.Runtime.Options; +using FluentMigrator.Runner; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Fresh DBs are built only by FluentMigrator. That is what issues #263 and #264 +/// actually break on: the stores write columns/tables the older migrations never created. +/// +public sealed class DapperKeyValuesAndBookmarkQueueTests : IDisposable +{ + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-kv-bq-{Guid.NewGuid():N}.db"); + private readonly string _connectionString; + + public DapperKeyValuesAndBookmarkQueueTests() + { + _connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + } + + [Fact(DisplayName = "#263: a migration-built DB can Save / Find / FindMany / Delete through IKeyValueStore")] + public async Task FreshMigratedDatabase_KeyValueStore_RoundTrips() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.SaveAsync(Pair("app:alpha", "one"), CancellationToken.None); + await store.SaveAsync(Pair("app:beta", "two"), CancellationToken.None); + await store.SaveAsync(Pair("other:gamma", "three"), CancellationToken.None); + + // Act + var alpha = await store.FindAsync(new KeyValueFilter { Key = "app:alpha" }, CancellationToken.None); + var many = (await store.FindManyAsync(new KeyValueFilter { Keys = ["app:alpha", "app:beta"] }, CancellationToken.None)).ToList(); + await store.DeleteAsync("app:beta", CancellationToken.None); + var deleted = await store.FindAsync(new KeyValueFilter { Key = "app:beta" }, CancellationToken.None); + var remaining = (await store.FindManyAsync(new KeyValueFilter { Keys = ["app:alpha", "app:beta", "other:gamma"] }, CancellationToken.None)).ToList(); + + // Assert + Assert.True(TableExists("KeyValues")); + Assert.Equal("one", alpha?.SerializedValue); + Assert.Equal(["app:alpha", "app:beta"], many.Select(x => x.Key).Order().ToArray()); + Assert.Null(deleted); + Assert.Equal(["app:alpha", "other:gamma"], remaining.Select(x => x.Key).Order().ToArray()); + } + + [Fact(DisplayName = "#263: prefix FindMany returns only matching keys and exact Find still works")] + public async Task FreshMigratedDatabase_KeyValueStore_PrefixFindMany_ReturnsMatchingKeys() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.SaveAsync(Pair("app:1", "one"), CancellationToken.None); + await store.SaveAsync(Pair("app:2", "two"), CancellationToken.None); + await store.SaveAsync(Pair("other:1", "other"), CancellationToken.None); + + // Act + var prefixed = (await store.FindManyAsync(new KeyValueFilter { Key = "app:", StartsWith = true }, CancellationToken.None)).ToList(); + var exact = await store.FindAsync(new KeyValueFilter { Key = "app:1" }, CancellationToken.None); + var missing = await store.FindAsync(new KeyValueFilter { Key = "app:" }, CancellationToken.None); + + // Assert + Assert.Equal(["app:1", "app:2"], prefixed.Select(x => x.Key).Order().ToArray()); + Assert.DoesNotContain(prefixed, x => x.Key == "other:1"); + Assert.Equal("one", exact?.SerializedValue); + Assert.Null(missing); + } + + [Fact(DisplayName = "#263: the KV outbox store can Save and FindMany on a migration-built DB")] + public async Task FreshMigratedDatabase_OutboxStore_SaveAndFindMany() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var outbox = scope.ServiceProvider.GetRequiredService(); + var item = new WorkflowDispatchOutboxItem + { + Id = "outbox-1", + OwnerWorkflowInstanceId = "wf-owner", + Kind = WorkflowDispatchOutboxItemKind.WorkflowDefinition, + CreatedAt = DateTimeOffset.Parse("2026-09-28T00:00:00+00:00") + }; + + // Act + await outbox.SaveAsync(item, CancellationToken.None); + var found = (await outbox.FindManyAsync(CancellationToken.None)).ToList(); + + // Assert + var loaded = Assert.Single(found); + Assert.Equal("outbox-1", loaded.Id); + Assert.Equal("wf-owner", loaded.OwnerWorkflowInstanceId); + } + + [Fact(DisplayName = "#264: a migration-built DB can enqueue a bookmark-queue item with Options and read it back")] + public async Task FreshMigratedDatabase_BookmarkQueueStore_RoundTripsOptions() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(); + using var scope = services.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().PushContext(Tenant.Default); + var store = scope.ServiceProvider.GetRequiredService(); + var item = new BookmarkQueueItem + { + Id = "bq-1", + WorkflowInstanceId = "wf-1", + BookmarkId = "bm-1", + StimulusHash = "hash-1", + ActivityInstanceId = "act-1", + ActivityTypeName = "Elsa.Event", + CreatedAt = DateTimeOffset.Parse("2026-09-28T00:00:00+00:00"), + Options = new ResumeBookmarkOptions + { + Input = new Dictionary { ["payload"] = "hello" }, + Properties = new Dictionary { ["source"] = "test" } + } + }; + + // Act + await store.AddAsync(item, CancellationToken.None); + var loaded = await store.FindAsync(new BookmarkQueueFilter { Id = item.Id }, CancellationToken.None); + + // Assert + Assert.Contains("SerializedOptions", TableColumns("BookmarkQueueItems")); + Assert.NotNull(loaded); + Assert.NotNull(loaded.Options); + Assert.Equal("hello", loaded.Options.Input?["payload"]?.ToString()); + Assert.Equal("test", loaded.Options.Properties?["source"]?.ToString()); + } + + [Fact(DisplayName = "#264: Event start + resume completes on a migration-built Dapper DB")] + public async Task FreshMigratedDatabase_EventResumeWorkflow_ReachesFinished() + { + // Arrange + MigrateUp(); + await using var services = CreateElsaServices(elsa => elsa.AddWorkflow()); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + using var tenant = sp.GetRequiredService().PushContext(Tenant.Default); + await sp.GetRequiredService().PopulateAsync(); + var publisher = sp.GetRequiredService(); + var instances = sp.GetRequiredService(); + + // Act + await publisher.PublishAsync("probe-start"); + var afterStart = (await instances.FindManyAsync(new WorkflowInstanceFilter())).ToList(); + await publisher.PublishAsync("probe-resume"); + var afterResume = (await instances.FindManyAsync(new WorkflowInstanceFilter())).ToList(); + + // Assert + var started = Assert.Single(afterStart); + Assert.Equal(WorkflowStatus.Running, started.Status); + var finished = Assert.Single(afterResume); + Assert.Equal(WorkflowStatus.Finished, finished.Status); + } + + [Fact(DisplayName = "V3_9 is a no-op when KeyValues and SerializedOptions already exist")] + public void Migration_IsNoOp_WhenKeyValuesAndSerializedOptionsAlreadyExist() + { + // Arrange: stop at V3_7, then hand-create the objects a user may already have. + MigrateUp(20007); + Assert.False(TableExists("KeyValues")); + Assert.DoesNotContain("SerializedOptions", TableColumns("BookmarkQueueItems")); + + using (var connection = new SqliteConnection(_connectionString)) + { + connection.Execute(""" + create table KeyValues ( + Id text not null primary key, + TenantId text null, + Value text null + ); + insert into KeyValues (Id, TenantId, Value) values ('kept-key', '', 'kept-value'); + alter table BookmarkQueueItems add column SerializedOptions text null; + """); + } + + // Act + MigrateUp(); + + // Assert + Assert.True(TableExists("KeyValues")); + Assert.Contains("SerializedOptions", TableColumns("BookmarkQueueItems")); + using var read = new SqliteConnection(_connectionString); + var value = read.ExecuteScalar("select Value from KeyValues where Id = 'kept-key'"); + Assert.Equal("kept-value", value); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private ServiceProvider CreateElsaServices(Action? extra = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddElsa(elsa => + { + elsa.UseDapper(d => d.DbConnectionProvider = _ => new SqliteDbConnectionProvider(_connectionString)); + elsa.UseWorkflowManagement(m => m.UseDapper()); + elsa.UseWorkflowRuntime(r => r.UseDapper()); + extra?.Invoke(elsa); + }); + return services.BuildServiceProvider(); + } + + private void MigrateUp(long? targetVersion = null) + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddSQLite() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + if (targetVersion is { } version) + runner.MigrateUp(version); + else + runner.MigrateUp(); + } + + private IReadOnlyCollection TableColumns(string tableName) + { + using var connection = new SqliteConnection(_connectionString); + return connection.Query($"SELECT name FROM pragma_table_info('{tableName}')").ToList(); + } + + private bool TableExists(string tableName) + { + using var connection = new SqliteConnection(_connectionString); + return connection.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @tableName", + new { tableName }) == 1; + } + + private static SerializedKeyValuePair Pair(string key, string value) => new() + { + Key = key, + SerializedValue = value + }; + + private sealed class EventResumeProbeWorkflow : WorkflowBase + { + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new Event("probe-start") { CanStartWorkflow = true }, + new Event("probe-resume"), + new WriteLine("resumed") + } + }; + } + } +} diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs index 6d260b675e..118e5b9fb2 100644 --- a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -250,6 +250,11 @@ private async Task ExerciseListSearchOrderVersionAndPagedDelete() new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); Assert.True(studioInstances.TotalCount >= 1); + var beforeLastUpdated = (await instances.FindManyAsync( + new WorkflowInstanceFilter { BeforeLastUpdated = DateTimeOffset.UtcNow.AddMinutes(1) }, + new WorkflowInstanceOrder(x => x.UpdatedAt, OrderDirection.Ascending))).ToList(); + Assert.Contains(beforeLastUpdated, instance => instance.Id == "wf-pg-1"); + var journal = sp.GetRequiredService(); await journal.SaveAsync(new WorkflowExecutionLogRecord { diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index d006fe6bfd..0ad8e97c62 100644 --- a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -13,6 +13,7 @@ + diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs index fecd4a1c21..d4470bdaf8 100644 --- a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -9,9 +9,8 @@ namespace Elsa.Persistence.Dapper.UnitTests; /// /// Captured from the query builder on this branch before QuoteIdentifier was wired through -/// ParameterizedQueryBuilderExtensions. SQLite and SQL Server (the only non-PG dialects -/// in this repo; MySQL/Oracle use the same unquoted SqlDialectBase path) must stay -/// byte-identical to these strings. +/// ParameterizedQueryBuilderExtensions. The expected SQL preserves each dialect's contract, +/// including intentional query-builder changes such as SQLite instant comparisons. /// public sealed class NonPgQuerySqlSnapshotTests { @@ -118,6 +117,11 @@ private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary SqliteExpected = Merge(SharedExpected, new Dictionary { + ["less-than"] = Join( + "select * from WorkflowInstances where 1=1", + "and julianday(UpdatedAt) < julianday(@UpdatedAt)"), ["paged-delete"] = Join( "delete from WorkflowInstances where 1=1", "and Id in (", diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs index 34bba44b08..9958d46b0b 100644 --- a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/ParameterizedQueryBuilderExtensionsTests.cs @@ -1,6 +1,7 @@ using Elsa.Common.Multitenancy; using Elsa.Common.Entities; using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Dialects; using Elsa.Persistence.Dapper.Extensions; using Elsa.Persistence.Dapper.Models; using Elsa.Persistence.Dapper.Services; @@ -83,6 +84,123 @@ public async Task In_WithValues_DoesNotMatchAnotherTenant() Assert.Equal(0, result.TotalCount); } + [Theory] + [InlineData("order_", "order_under", "orderXunder")] + [InlineData("order%", "order%percent", "orderXpercent")] + [InlineData("order!", "order!bang", "orderbang")] + [InlineData("order[", "order[bracket", "orderAbracket")] + [InlineData("path\\", "path\\item", "pathXitem")] + public async Task StartsWith_TreatsLikePatternCharactersAsLiterals(string prefix, string matchingId, string nonMatchingId) + { + var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + using var connection = new SqliteConnection(connectionString); + connection.Open(); + connection.Execute( + "insert into TestRecords (Id, TenantId, Value) values (@Id, 'tenant-a', 'value')", + new[] { new { Id = matchingId }, new { Id = nonMatchingId } }); + + var query = new ParameterizedQuery(new SqliteDialect()) + .From("TestRecords") + .StartsWith("Id", true, prefix); + + if (prefix.Contains('[')) + { + Assert.DoesNotContain("escape '!'", query.Sql.ToString(), StringComparison.Ordinal); + } + if (prefix.Contains('\\')) + { + Assert.Contains("escape '!'", query.Sql.ToString(), StringComparison.Ordinal); + } + + var actualIds = await connection.QueryAsync(query.Sql.ToString(), query.Parameters); + + Assert.Equal([matchingId], actualIds); + } + + [Fact] + public async Task LessThan_DateTimeOffset_ComparesInstantsAndKeepsCutoffExclusive() + { + var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + using var connection = new SqliteConnection(connectionString); + connection.Open(); + connection.Execute(""" + insert into TestRecords (Id, TenantId, Value) values + ('older-offset', 'tenant-a', '2026-01-01 17:00:00+05:00'), + ('newer-offset', 'tenant-a', '2026-01-01 08:00:00-05:00'), + ('same-instant-offset', 'tenant-a', '2026-01-01 07:05:00-05:00'); + """); + var cutoff = new DateTimeOffset(2026, 1, 1, 12, 5, 0, TimeSpan.Zero); + + var query = new ParameterizedQuery(new SqliteDialect()) + .From("TestRecords", nameof(TestRecord.Id)) + .LessThan(nameof(TestRecord.Value), cutoff); + + var actualIds = await connection.QueryAsync(query.Sql.ToString(), query.Parameters); + + Assert.Equal(["older-offset"], actualIds); + } + + [Fact(DisplayName = "StartsWith binds @{field}StartsWith so it does not collide with Is(@{field})")] + public void StartsWith_BindsFieldPrefixedParameter() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .StartsWith("Id", true, "app:"); + + var sql = query.Sql.ToString(); + + Assert.Contains("and Id like @IdStartsWith", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@SearchTermLike", sql, StringComparison.Ordinal); + Assert.Equal("app:%", query.Parameters.Get("IdStartsWith")); + Assert.DoesNotContain("Id", query.Parameters.ParameterNames.Where(name => name == "Id")); + } + + [Fact(DisplayName = "IsNullOrEmpty matches NULL or empty string for the default tenant")] + public void IsNullOrEmpty_MatchesNullOrEmptyTenantId() + { + var query = new ParameterizedQuery(new SqliteDialect()) + .From("KeyValues") + .IsNullOrEmpty("TenantId"); + + Assert.Contains("and (TenantId is null or TenantId = '')", query.Sql.ToString(), StringComparison.Ordinal); + } + + [Fact(DisplayName = "StartsWith quotes the identifier on PostgreSQL and binds @{field}StartsWith")] + public void StartsWith_PostgreSql_QuotesIdentifierAndBindsFieldPrefixedParameter() + { + var query = new ParameterizedQuery(new PostgreSqlDialect()) + .From("KeyValues") + .StartsWith("Id", true, "app:"); + + var sql = query.Sql.ToString(); + + Assert.Contains("and \"Id\" like @IdStartsWith", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@SearchTermLike", sql, StringComparison.Ordinal); + Assert.Equal("app:%", query.Parameters.Get("IdStartsWith")); + } + + [Fact(DisplayName = "StartsWith uses an explicit PostgreSQL escape when the prefix contains a backslash")] + public void StartsWith_PostgreSql_BackslashPrefixUsesExplicitEscape() + { + var query = new ParameterizedQuery(new PostgreSqlDialect()) + .From("KeyValues") + .StartsWith("Id", true, "path\\"); + + Assert.Contains("and \"Id\" like @IdStartsWith escape '!'", query.Sql.ToString(), StringComparison.Ordinal); + Assert.Equal("path\\%", query.Parameters.Get("IdStartsWith")); + } + + [Fact(DisplayName = "StartsWith escapes LIKE syntax in SQL Server patterns")] + public void StartsWith_SqlServer_EscapesLikePatternCharacters() + { + var query = new ParameterizedQuery(new SqlServerDialect()) + .From("KeyValues") + .StartsWith("Id", true, "order!_["); + + Assert.Contains("and Id like @IdStartsWith escape '!'", query.Sql.ToString(), StringComparison.Ordinal); + Assert.Equal("order!!!_![%", query.Parameters.Get("IdStartsWith")); + } + [Fact] public async Task In_WithTenantAgnosticFilter_ReturnsMatchingRowsAcrossTenants() { diff --git a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs index 8bf7c1d4fd..3a8caa40be 100644 --- a/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs +++ b/test/extensions/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -68,9 +68,11 @@ public void QueryBuilder_QuotesInlinedIdentifiers() Assert.Contains("and \"IsLatest\" = true", queries["version-latest"], StringComparison.Ordinal); Assert.Contains("and \"IsPublished\" = false", queries["version-draft"], StringComparison.Ordinal); Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); + Assert.Contains("and \"Name\" like @NameStartsWith", queries["starts-with"], StringComparison.Ordinal); Assert.Contains("\"Id\" like @SearchTerm", queries["instance-search"], StringComparison.Ordinal); Assert.DoesNotContain("\"ID\"", queries["instance-search"], StringComparison.Ordinal); Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); + Assert.Contains("and \"UpdatedAt\" < @UpdatedAt", queries["less-than"], StringComparison.Ordinal); Assert.DoesNotContain("= 1", queries["version-latest"], StringComparison.Ordinal); Assert.DoesNotContain("order by CreatedAt desc\n", queries["order-desc"], StringComparison.Ordinal); }