diff --git a/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs b/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs
new file mode 100644
index 000000000..34655fe6d
--- /dev/null
+++ b/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs
@@ -0,0 +1,20 @@
+// ------------------------------------------------------------------------
+// Copyright 2026 The Dapr Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Runtime.CompilerServices;
+
+// Allow Dapr.Workflow to call internal members (e.g. WorkflowAutoRegistry.Apply).
+[assembly: InternalsVisibleTo("Dapr.Workflow, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")]
+
+// Allow test projects to call internal members.
+[assembly: InternalsVisibleTo("Dapr.Workflow.Abstractions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")]
diff --git a/src/Dapr.Workflow.Abstractions/WorkflowAutoRegistry.cs b/src/Dapr.Workflow.Abstractions/WorkflowAutoRegistry.cs
new file mode 100644
index 000000000..bec734b8a
--- /dev/null
+++ b/src/Dapr.Workflow.Abstractions/WorkflowAutoRegistry.cs
@@ -0,0 +1,88 @@
+// ------------------------------------------------------------------------
+// Copyright 2026 The Dapr Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Runtime.CompilerServices;
+
+namespace Dapr.Workflow;
+
+///
+/// Static registration point for source-generated workflow and activity auto-registrations.
+/// The Dapr Workflow source generator emits a [ModuleInitializer] that calls
+/// at process startup. AddDaprWorkflow() then calls
+/// to push all discovered types into
+/// before the workflow factory is built — meaning application code never has to call
+/// RegisterWorkflow or RegisterActivity manually.
+///
+///
+/// Registrations stored here use first-write-wins semantics (via
+///
+/// inside WorkflowsFactory), so any explicit registrations made by
+/// the application in its AddDaprWorkflow(configure) callback take precedence over
+/// the auto-discovered ones without conflict.
+///
+public static class WorkflowAutoRegistry
+{
+ private static readonly object SyncLock = new();
+ private static readonly List> Registrations = [];
+ private static readonly HashSet AppliedOptions = new(ReferenceEqualityComparer.Instance);
+
+ ///
+ /// Registers a source-generated auto-registration callback.
+ /// Called from [ModuleInitializer] code emitted by the Dapr Workflow source generator.
+ /// Safe to call from multiple assemblies; duplicate delegates are silently ignored.
+ ///
+ ///
+ /// An action that registers discovered workflows and activities with the provided
+ /// .
+ ///
+ public static void Register(Action registration)
+ {
+ ArgumentNullException.ThrowIfNull(registration);
+
+ lock (SyncLock)
+ {
+ if (!Registrations.Contains(registration))
+ Registrations.Add(registration);
+ }
+ }
+
+ ///
+ /// Applies all source-generated auto-registrations to .
+ /// Called once per instance (idempotent on repeated calls).
+ ///
+ internal static void Apply(WorkflowRuntimeOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ List> snapshot;
+
+ lock (SyncLock)
+ {
+ // Guard against multiple AddDaprWorkflow() calls that share the same options instance.
+ if (!AppliedOptions.Add(options))
+ return;
+
+ snapshot = [.. Registrations];
+ }
+
+ foreach (var registration in snapshot)
+ registration(options);
+ }
+
+ private sealed class ReferenceEqualityComparer : IEqualityComparer
+ {
+ public static readonly ReferenceEqualityComparer Instance = new();
+ public bool Equals(WorkflowRuntimeOptions? x, WorkflowRuntimeOptions? y) => ReferenceEquals(x, y);
+ public int GetHashCode(WorkflowRuntimeOptions obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+}
diff --git a/src/Dapr.Workflow.Versioning.Generators/KnownSymbols.cs b/src/Dapr.Workflow.Versioning.Generators/KnownSymbols.cs
index f87b728ac..81a151c2b 100644
--- a/src/Dapr.Workflow.Versioning.Generators/KnownSymbols.cs
+++ b/src/Dapr.Workflow.Versioning.Generators/KnownSymbols.cs
@@ -15,4 +15,7 @@
namespace Dapr.Workflow.Versioning;
-internal sealed record KnownSymbols(INamedTypeSymbol? WorkflowBase, INamedTypeSymbol? WorkflowVersionAttribute);
+internal sealed record KnownSymbols(
+ INamedTypeSymbol? WorkflowBase,
+ INamedTypeSymbol? WorkflowVersionAttribute,
+ INamedTypeSymbol? ActivityBase);
diff --git a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs
index b4219f033..11f70016d 100644
--- a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs
+++ b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs
@@ -22,15 +22,20 @@
namespace Dapr.Workflow.Versioning;
///
-/// Incremental source generator that discovers Dapr Workflow types, reads optional versioning metadata
-/// and emits a registry that:
-/// - registers all workflow implementations, and
-/// - produces a canonical-name registry ordered by version using the configured strategy and selector.
+/// Incremental source generator that discovers Dapr Workflow and Activity types, reads optional
+/// versioning metadata, and emits a registry that:
+///
+/// - registers all workflow and activity implementations via
+/// WorkflowAutoRegistry (simple, no-config path for plain AddDaprWorkflow()), and
+/// - produces a canonical-name registry ordered by version using the configured strategy
+/// and selector (versioning path via WorkflowVersioningRegistry).
+///
///
[Generator(LanguageNames.CSharp)]
public sealed class WorkflowSourceGenerator : IIncrementalGenerator
{
private const string WorkflowBaseMetadataName = "Dapr.Workflow.Workflow`2";
+ private const string ActivityBaseMetadataName = "Dapr.Workflow.WorkflowActivity`2";
private const string WorkflowVersionAttributeFullName = "Dapr.Workflow.Versioning.WorkflowVersionAttribute";
private const string ScanReferencesPropertyName = "build_property.DaprWorkflowVersioningScanReferences";
@@ -41,11 +46,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
options.GlobalOptions.TryGetValue(ScanReferencesPropertyName, out var value) &&
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase));
- // Cache the attribute symbol
+ // Cache the known symbols
var known = context.CompilationProvider.Select((c, _) =>
new KnownSymbols(
WorkflowBase: c.GetTypeByMetadataName(WorkflowBaseMetadataName),
- WorkflowVersionAttribute: c.GetTypeByMetadataName(WorkflowVersionAttributeFullName)));
+ WorkflowVersionAttribute: c.GetTypeByMetadataName(WorkflowVersionAttributeFullName),
+ ActivityBase: c.GetTypeByMetadataName(ActivityBaseMetadataName)));
// Report diagnostic about base type resolution
context.RegisterSourceOutput(known, (spc, ks) =>
@@ -76,7 +82,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
}
});
- // Discover candidate class symbols
+ // Discover candidate class symbols (shared between workflow and activity pipelines)
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => node is ClassDeclarationSyntax { BaseList: not null },
static (ctx, _) =>
@@ -98,10 +104,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
Location.None));
});
- // Combine the attribute symbol with each candidate symbol
+ // ── Workflow discovery pipeline ──────────────────────────────────────────
+
var inputs = candidates.Combine(known);
- // Filter and transform with proper symbol equality checks, tracking each step
+ // Filter and transform with proper symbol equality checks
var discoveredWithDiagnostics = inputs
.Select((pair, _) =>
{
@@ -114,9 +121,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
// Check derives from Dapr.Workflow.Workflow<,>
if (!InheritsFromWorkflow(symbol, ks.WorkflowBase))
{
- // Report why this candidate was rejected
var baseTypeInfo = symbol.BaseType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? "null";
- return (Workflow: (DiscoveredWorkflow?)null, Diagnostic: (string?)$"Rejected '{symbolName}': does not inherit from Workflow<,> (base type: {baseTypeInfo})");
+ return (Workflow: (DiscoveredWorkflow?)null,
+ Diagnostic: (string?)$"Rejected '{symbolName}': does not inherit from Workflow<,> (base type: {baseTypeInfo})");
}
// Look for [WorkflowVersion] by symbol identity
@@ -136,56 +143,48 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return (Workflow: (DiscoveredWorkflow?)workflow, Diagnostic: (string?)$"Discovered workflow: '{symbolName}'");
});
- // Separate workflows from diagnostics
var discovered = discoveredWithDiagnostics
.Select((item, _) => item.Workflow)
.Where(x => x is not null);
- // Report diagnostics about filtering
+ // Report diagnostics about workflow filtering
context.RegisterSourceOutput(discoveredWithDiagnostics.Collect(), (spc, items) =>
{
foreach (var item in items)
{
if (item.Diagnostic is not null)
{
- const DiagnosticSeverity severity = DiagnosticSeverity.Info;
spc.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
"DAPRWFVER005",
"Workflow filtering",
item.Diagnostic,
"Dapr.Workflow.Versioning",
- severity,
+ DiagnosticSeverity.Info,
isEnabledByDefault: true),
Location.None));
}
}
});
- var referenced = context.CompilationProvider
+ var referencedWorkflows = context.CompilationProvider
.Combine(known)
.Combine(scanReferences)
.Select((input, _) =>
{
var ((compilation, ks), scan) = input;
- if (!scan)
- return ImmutableArray.Empty;
-
- if (ks.WorkflowBase is null)
+ if (!scan || ks.WorkflowBase is null)
return ImmutableArray.Empty;
var list = new List();
foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols)
- {
list.AddRange(DiscoverReferencedWorkflows(assembly, ks, compilation.Assembly));
- }
return list.ToImmutableArray();
});
- // Collect and emit
- var discoveredAll = discovered.Collect()
- .Combine(referenced)
+ var discoveredAllWorkflows = discovered.Collect()
+ .Combine(referencedWorkflows)
.Select((input, _) =>
{
var (current, extra) = input;
@@ -198,19 +197,79 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return list.ToImmutableArray();
});
- context.RegisterSourceOutput(discoveredAll, (spc, items) =>
+ // ── Activity discovery pipeline ──────────────────────────────────────────
+
+ var discoveredActivities = inputs
+ .Select((pair, _) =>
+ {
+ var (symbol, ks) = pair;
+ // Abstract or open-generic types cannot be instantiated; skip them.
+ if (symbol is null || symbol.IsAbstract || symbol.TypeParameters.Length > 0)
+ return (DiscoveredActivity?)null;
+
+ if (!IsAccessibleFromAssembly(symbol, symbol.ContainingAssembly))
+ return null;
+
+ if (!InheritsFromActivity(symbol, ks.ActivityBase))
+ return null;
+
+ return new DiscoveredActivity(
+ ActivityTypeName: symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ ActivitySimpleName: symbol.Name);
+ })
+ .Where(x => x is not null);
+
+ var referencedActivities = context.CompilationProvider
+ .Combine(known)
+ .Combine(scanReferences)
+ .Select((input, _) =>
+ {
+ var ((compilation, ks), scan) = input;
+ if (!scan || ks.ActivityBase is null)
+ return ImmutableArray.Empty;
+
+ var list = new List();
+ foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols)
+ list.AddRange(DiscoverReferencedActivities(assembly, ks, compilation.Assembly));
+
+ return list.ToImmutableArray();
+ });
+
+ var discoveredAllActivities = discoveredActivities.Collect()
+ .Combine(referencedActivities)
+ .Select((input, _) =>
+ {
+ var (current, extra) = input;
+ if (extra.IsDefaultOrEmpty)
+ return current;
+
+ var list = new List(current.Length + extra.Length);
+ list.AddRange(current);
+ list.AddRange(extra);
+ return list.ToImmutableArray();
+ });
+
+ // ── Combined emission ────────────────────────────────────────────────────
+
+ var allDiscovered = discoveredAllWorkflows.Combine(discoveredAllActivities);
+
+ context.RegisterSourceOutput(allDiscovered, (spc, items) =>
{
- var workflows = items.Where(x => x is not null).ToList();
+ var (workflowItems, activityItems) = items;
+ var workflows = workflowItems.Where(x => x is not null).ToList();
+ var activities = activityItems.Where(x => x is not null).ToList();
- // Always show a visible message about workflow discovery (Info level to avoid build warnings)
- if (workflows.Count > 0)
+ if (workflows.Count > 0 || activities.Count > 0)
{
- var workflowNames = string.Join(", ", workflows.Select(w => w!.WorkflowTypeName.Split('.').Last()));
+ var allNames = string.Join(", ",
+ workflows.Select(w => w!.WorkflowTypeName.Split('.').Last())
+ .Concat(activities.Select(a => a!.ActivityTypeName.Split('.').Last())));
+
spc.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
"DAPRWFVER008",
- "Workflow versioning active",
- $"Dapr Workflow Versioning: Discovered {workflows.Count} workflow(s): {workflowNames}. Build with -v:n to see this message.",
+ "Workflow auto-registration active",
+ $"Dapr Workflow source generator: discovered {workflows.Count} workflow(s) and {activities.Count} activity/activities: {allNames}. Build with -v:n to see this message.",
"Dapr.Workflow.Versioning",
DiagnosticSeverity.Info,
isEnabledByDefault: true,
@@ -218,12 +277,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
Location.None));
}
- // Detailed info for verbose builds
spc.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
"DAPRWFVER002",
- "Final workflow count",
- $"Source generator will generate registry for {workflows.Count} workflow(s). To view generated code, add true to your project file.",
+ "Final type count",
+ $"Source generator will generate registry for {workflows.Count} workflow(s) and {activities.Count} activity/activities. To view generated code, add true to your project file.",
"Dapr.Workflow.Versioning",
DiagnosticSeverity.Info,
isEnabledByDefault: true),
@@ -248,20 +306,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
new DiagnosticDescriptor(
"DAPRWFVER007",
"Generated file info",
- $"Generated 'Dapr_Workflow_Versioning.g.cs' with workflow registration code. Set EmitCompilerGeneratedFiles=true in your project to write generated files to disk at obj/$(Configuration)/$(TargetFramework)/generated/",
+ "Generated 'Dapr_Workflow_Versioning.g.cs' with workflow and activity registration code. Set EmitCompilerGeneratedFiles=true in your project to write generated files to disk at obj/$(Configuration)/$(TargetFramework)/generated/",
"Dapr.Workflow.Versioning",
DiagnosticSeverity.Info,
isEnabledByDefault: true),
Location.None));
}
- EmitRegistry(spc, items);
+ EmitRegistry(spc, workflowItems, activityItems);
});
}
+ // ── Inheritance helpers ─────────────────────────────────────────────────────
+
private static bool InheritsFromWorkflow(INamedTypeSymbol symbol, INamedTypeSymbol? workflowBase)
{
- if (workflowBase is null) return false; // Consumer didn’t reference Dapr.Workflow (no Workflows present)
+ if (workflowBase is null) return false;
for (var t = symbol.BaseType; t is not null; t = t.BaseType)
{
@@ -274,6 +334,23 @@ private static bool InheritsFromWorkflow(INamedTypeSymbol symbol, INamedTypeSymb
return false;
}
+ private static bool InheritsFromActivity(INamedTypeSymbol symbol, INamedTypeSymbol? activityBase)
+ {
+ if (activityBase is null) return false;
+
+ for (var t = symbol.BaseType; t is not null; t = t.BaseType)
+ {
+ var od = t.OriginalDefinition;
+ if (od is INamedTypeSymbol &&
+ SymbolEqualityComparer.Default.Equals(od, activityBase))
+ return true;
+ }
+
+ return false;
+ }
+
+ // ── Discovery builders ──────────────────────────────────────────────────────
+
private static DiscoveredWorkflow BuildDiscoveredWorkflow(
INamedTypeSymbol workflowSymbol,
AttributeData? workflowVersionAttribute)
@@ -287,26 +364,20 @@ private static DiscoveredWorkflow BuildDiscoveredWorkflow(
{
foreach (var kvp in workflowVersionAttribute.NamedArguments)
{
- var name = kvp.Key;
- var tc = kvp.Value;
-
- switch (name)
+ switch (kvp.Key)
{
case "CanonicalName":
- canonical = tc.Value?.ToString();
+ canonical = kvp.Value.Value?.ToString();
break;
case "Version":
- version = tc.Value?.ToString();
+ version = kvp.Value.Value?.ToString();
break;
case "OptionsName":
- optionsName = tc.Value?.ToString();
+ optionsName = kvp.Value.Value?.ToString();
break;
case "StrategyType":
- if (tc.Value is INamedTypeSymbol typeSym)
- {
+ if (kvp.Value.Value is INamedTypeSymbol typeSym)
strategyTypeName = typeSym.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- }
-
break;
}
}
@@ -315,6 +386,7 @@ private static DiscoveredWorkflow BuildDiscoveredWorkflow(
return new DiscoveredWorkflow(
WorkflowTypeName: workflowSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
WorkflowSimpleName: workflowSymbol.Name,
+ IsAbstractOrGeneric: workflowSymbol.IsAbstract || workflowSymbol.TypeParameters.Length > 0,
DeclaredCanonicalName: string.IsNullOrWhiteSpace(canonical) ? null : canonical,
DeclaredVersion: string.IsNullOrWhiteSpace(version) ? null : version,
StrategyTypeName: strategyTypeName,
@@ -322,6 +394,8 @@ private static DiscoveredWorkflow BuildDiscoveredWorkflow(
);
}
+ // ── Referenced-assembly scanning ────────────────────────────────────────────
+
private static IEnumerable DiscoverReferencedWorkflows(
IAssemblySymbol assemblySymbol,
KnownSymbols knownSymbols,
@@ -339,7 +413,8 @@ private static IEnumerable DiscoverReferencedWorkflows(
if (knownSymbols.WorkflowVersionAttribute is not null)
{
attrData = type.GetAttributes()
- .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, knownSymbols.WorkflowVersionAttribute));
+ .FirstOrDefault(a =>
+ SymbolEqualityComparer.Default.Equals(a.AttributeClass, knownSymbols.WorkflowVersionAttribute));
}
attrData ??= type.GetAttributes().FirstOrDefault(a =>
@@ -350,6 +425,31 @@ private static IEnumerable DiscoverReferencedWorkflows(
}
}
+ private static IEnumerable DiscoverReferencedActivities(
+ IAssemblySymbol assemblySymbol,
+ KnownSymbols knownSymbols,
+ IAssemblySymbol currentAssembly)
+ {
+ foreach (var type in EnumerateTypes(assemblySymbol.GlobalNamespace))
+ {
+ if (!IsAccessibleFromAssembly(type, currentAssembly))
+ continue;
+
+ // Skip abstract types and open generics — they can't be instantiated.
+ if (type.IsAbstract || type.TypeParameters.Length > 0)
+ continue;
+
+ if (!InheritsFromActivity(type, knownSymbols.ActivityBase))
+ continue;
+
+ yield return new DiscoveredActivity(
+ ActivityTypeName: type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ ActivitySimpleName: type.Name);
+ }
+ }
+
+ // ── Namespace/type enumeration ──────────────────────────────────────────────
+
private static IEnumerable EnumerateTypes(INamespaceSymbol root)
{
foreach (var member in root.GetMembers())
@@ -378,6 +478,8 @@ private static IEnumerable EnumerateNestedTypes(INamedTypeSymb
}
}
+ // ── Accessibility helpers ───────────────────────────────────────────────────
+
private static bool IsAccessibleFromAssembly(INamedTypeSymbol type, IAssemblySymbol currentAssembly)
{
for (var containing = type; containing is not null; containing = containing.ContainingType)
@@ -403,39 +505,48 @@ private static bool IsAccessibleCore(INamedTypeSymbol type, IAssemblySymbol curr
}
}
+ // ── Emission ────────────────────────────────────────────────────────────────
+
///
- /// Final source-emission step for the generator. Receives the collected set of discovered
- /// workflow descriptors and adds the generated registry/registration source to the compilation.
+ /// Final source-emission step. Adds the generated registry source to the compilation.
///
private static void EmitRegistry(
SourceProductionContext context,
- ImmutableArray discoveredItems)
+ ImmutableArray workflowItems,
+ ImmutableArray activityItems)
{
- // Nothing to emit if we found no workflows.
- if (discoveredItems.IsDefaultOrEmpty)
- return;
-
- // Remove nulls, de-dupe by fully-qualified type name, and stabilize the order for deterministic output.
- var list = discoveredItems
- .Where(x => x is not null)
- .Select(x => x!)
- .Distinct(new DiscoveredWorkflowComparer())
- .OrderBy(x => x.WorkflowTypeName, StringComparer.Ordinal)
- .ThenBy(x => x.DeclaredCanonicalName ?? string.Empty, StringComparer.Ordinal)
- .ThenBy(x => x.DeclaredVersion ?? string.Empty, StringComparer.Ordinal)
- .ThenBy(x => x.StrategyTypeName ?? string.Empty, StringComparer.Ordinal)
- .ThenBy(x => x.OptionsName ?? string.Empty, StringComparer.Ordinal)
- .ToList();
-
- if (list.Count == 0)
+ var workflows = workflowItems.IsDefaultOrEmpty
+ ? []
+ : workflowItems
+ .Where(x => x is not null)
+ .Select(x => x!)
+ .Distinct(new DiscoveredWorkflowComparer())
+ .OrderBy(x => x.WorkflowTypeName, StringComparer.Ordinal)
+ .ThenBy(x => x.DeclaredCanonicalName ?? string.Empty, StringComparer.Ordinal)
+ .ThenBy(x => x.DeclaredVersion ?? string.Empty, StringComparer.Ordinal)
+ .ThenBy(x => x.StrategyTypeName ?? string.Empty, StringComparer.Ordinal)
+ .ThenBy(x => x.OptionsName ?? string.Empty, StringComparer.Ordinal)
+ .ToList();
+
+ var activities = activityItems.IsDefaultOrEmpty
+ ? []
+ : activityItems
+ .Where(x => x is not null)
+ .Select(x => x!)
+ .Distinct(new DiscoveredActivityComparer())
+ .OrderBy(x => x.ActivityTypeName, StringComparer.Ordinal)
+ .ToList();
+
+ if (workflows.Count == 0 && activities.Count == 0)
return;
- // Generate the full source and add it to the compilation.
- var source = GenerateRegistrySource(list);
+ var source = GenerateRegistrySource(workflows, activities);
context.AddSource("Dapr_Workflow_Versioning.g.cs", source);
}
- private static string GenerateRegistrySource(IReadOnlyList discovered)
+ private static string GenerateRegistrySource(
+ IReadOnlyList workflows,
+ IReadOnlyList activities)
{
var sb = new StringBuilder();
@@ -451,16 +562,23 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine();
sb.AppendLine("namespace Dapr.Workflow.Versioning");
sb.AppendLine("{");
+
+ // ── GeneratedWorkflowVersionRegistry ────────────────────────────────────
sb.AppendLine(" /// ");
- sb.AppendLine(" /// Generated workflow registry that registers discovered workflows and");
- sb.AppendLine(" /// provides a canonical-name mapping ordered by version.");
+ sb.AppendLine(" /// Generated workflow and activity registry that:");
+ sb.AppendLine(" /// ");
+ sb.AppendLine(" /// - auto-registers all discovered types via (used by AddDaprWorkflow()), and
");
+ sb.AppendLine(" /// - provides a canonical-name mapping ordered by version for the optional versioning path.
");
+ sb.AppendLine(" ///
");
sb.AppendLine(" /// ");
sb.AppendLine(" internal static partial class GeneratedWorkflowVersionRegistry");
sb.AppendLine(" {");
+
+ // RegisterAlias helper (versioning path)
sb.AppendLine(" private static void RegisterAlias(global::Dapr.Workflow.WorkflowRuntimeOptions options, string canonical, string latestName)");
sb.AppendLine(" {");
bool firstAlias = true;
- foreach (var wf in discovered)
+ foreach (var wf in workflows)
{
var cond = $"string.Equals(latestName, {CodeLiteral(wf.WorkflowTypeName)}, StringComparison.Ordinal)";
sb.AppendLine(firstAlias
@@ -468,14 +586,19 @@ private static string GenerateRegistrySource(IReadOnlyList d
: $" else if ({cond}) options.RegisterWorkflow<{wf.WorkflowTypeName}>(canonical);");
firstAlias = false;
}
- sb.AppendLine(" else");
- sb.AppendLine(" {");
- sb.AppendLine(" throw new InvalidOperationException($\"No registration method generated for selected type '{latestName}'.\");");
- sb.AppendLine(" }");
+
+ if (workflows.Count > 0)
+ {
+ sb.AppendLine(" else");
+ sb.AppendLine(" {");
+ sb.AppendLine(" throw new InvalidOperationException($\"No registration method generated for selected type '{latestName}'.\");");
+ sb.AppendLine(" }");
+ }
+
sb.AppendLine(" }");
sb.AppendLine();
- // Emit runtime registration entry struct to carry discovery + declared hints.
+ // Entry struct (versioning path)
sb.AppendLine(" private readonly struct Entry");
sb.AppendLine(" {");
sb.AppendLine(" public readonly Type WorkflowType;");
@@ -485,8 +608,7 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" public readonly Type? StrategyType;");
sb.AppendLine(" public readonly string? OptionsName;");
sb.AppendLine();
- sb.AppendLine(
- " public Entry(Type wfType, string wfName, string? canonical, string? version, Type? strategyType, string? optionsName)");
+ sb.AppendLine(" public Entry(Type wfType, string wfName, string? canonical, string? version, Type? strategyType, string? optionsName)");
sb.AppendLine(" {");
sb.AppendLine(" WorkflowType = wfType;");
sb.AppendLine(" WorkflowTypeName = wfName;");
@@ -498,32 +620,64 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" }");
sb.AppendLine();
- // Emit public API: RegisterGeneratedWorkflows
+ // ── RegisterDiscoveredTypes (simple, no-versioning path) ─────────────────
+ sb.AppendLine(" /// ");
+ sb.AppendLine(" /// Registers all discovered workflow and activity types using their simple (short) type name.");
+ sb.AppendLine(" /// Called automatically by AddDaprWorkflow() via .");
+ sb.AppendLine(" /// Duplicate registrations are silently ignored — explicit user registrations always take precedence.");
+ sb.AppendLine(" /// ");
+ sb.AppendLine(" public static void RegisterDiscoveredTypes(global::Dapr.Workflow.WorkflowRuntimeOptions options)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" if (options is null) throw new ArgumentNullException(nameof(options));");
+ sb.AppendLine();
+
+ // Register concrete (non-abstract, non-generic) workflows by simple name
+ var concreteWorkflows = workflows.Where(w => !w.IsAbstractOrGeneric).ToList();
+ if (concreteWorkflows.Count > 0)
+ {
+ sb.AppendLine(" // Discovered workflows (registered by simple type name)");
+ foreach (var wf in concreteWorkflows)
+ {
+ sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>();");
+ }
+ }
+
+ if (activities.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(" // Discovered activities (registered by simple type name)");
+ foreach (var act in activities)
+ {
+ sb.AppendLine($" options.RegisterActivity<{act.ActivityTypeName}>();");
+ }
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine();
+
+ // ── RegisterGeneratedWorkflows (full versioning path) ────────────────────
sb.AppendLine(" /// ");
sb.AppendLine(" /// Registers all discovered workflow types with the Dapr Workflow runtime and");
sb.AppendLine(" /// registers canonical-name aliases that route to the selected latest version.");
+ sb.AppendLine(" /// Requires workflow versioning to be configured via AddDaprWorkflowVersioning().");
sb.AppendLine(" /// ");
- sb.AppendLine(
- " /// The workflow runtime options used for registration.");
- sb.AppendLine(
- " /// Application service provider (DI root) used to resolve strategy/selector runtime services.");
- sb.AppendLine(
- " public static void RegisterGeneratedWorkflows(global::Dapr.Workflow.WorkflowRuntimeOptions options, global::System.IServiceProvider services)");
+ sb.AppendLine(" /// The workflow runtime options used for registration.");
+ sb.AppendLine(" /// Application service provider used to resolve strategy/selector runtime services.");
+ sb.AppendLine(" public static void RegisterGeneratedWorkflows(global::Dapr.Workflow.WorkflowRuntimeOptions options, global::System.IServiceProvider services)");
sb.AppendLine(" {");
sb.AppendLine(" if (options is null) throw new ArgumentNullException(nameof(options));");
sb.AppendLine(" if (services is null) throw new ArgumentNullException(nameof(services));");
sb.AppendLine();
-
sb.AppendLine(" var entries = CreateEntries();");
sb.AppendLine();
- sb.AppendLine(" // Register concrete workflow implementations with internal names to avoid collisions.");
- sb.AppendLine(" // These registrations use the fully-qualified type name as the workflow name.");
- foreach (var wf in discovered)
+ sb.AppendLine(" // Register concrete workflow implementations with internal fully-qualified names to avoid collisions.");
+
+ foreach (var wf in workflows)
{
sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>({CodeLiteral(wf.WorkflowTypeName)});");
}
- sb.AppendLine();
+ sb.AppendLine();
sb.AppendLine(" var registry = BuildRegistry(services, entries, out var latestMap);");
sb.AppendLine(" UpdateRouterRegistry(services, entries, registry);");
sb.AppendLine(" foreach (var kvp in latestMap)");
@@ -536,27 +690,25 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" // Register simple-name aliases for convenience. Collisions are resolved deterministically");
sb.AppendLine(" // by generator ordering (first registration wins).");
var simpleAliasNames = new HashSet(StringComparer.Ordinal);
- foreach (var wf in discovered)
+ foreach (var wf in workflows)
{
if (simpleAliasNames.Add(wf.WorkflowSimpleName))
{
sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>({CodeLiteral(wf.WorkflowSimpleName)});");
}
}
+
sb.AppendLine(" }");
sb.AppendLine();
- // Emit public API: GetWorkflowVersionRegistry
+ // GetWorkflowVersionRegistry
sb.AppendLine(" /// ");
sb.AppendLine(" /// Gets a mapping of canonical workflow names to ordered workflow names.");
sb.AppendLine(" /// The latest version (as selected by the configured selector) is first.");
sb.AppendLine(" /// ");
- sb.AppendLine(
- " /// Application service provider (DI root) used to resolve strategy/selector runtime services.");
- sb.AppendLine(
- " /// A read-only mapping of canonical names to ordered workflow names.");
- sb.AppendLine(
- " public static IReadOnlyDictionary> GetWorkflowVersionRegistry(global::System.IServiceProvider services)");
+ sb.AppendLine(" /// Application service provider used to resolve strategy/selector runtime services.");
+ sb.AppendLine(" /// A read-only mapping of canonical names to ordered workflow names.");
+ sb.AppendLine(" public static IReadOnlyDictionary> GetWorkflowVersionRegistry(global::System.IServiceProvider services)");
sb.AppendLine(" {");
sb.AppendLine(" if (services is null) throw new ArgumentNullException(nameof(services));");
sb.AppendLine(" var entries = CreateEntries();");
@@ -564,6 +716,7 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" }");
sb.AppendLine();
+ // UpdateRouterRegistry
sb.AppendLine(" private static void UpdateRouterRegistry(global::System.IServiceProvider services, List entries, IReadOnlyDictionary> registry)");
sb.AppendLine(" {");
sb.AppendLine(" var routerRegistry = services.GetService(typeof(global::Dapr.Workflow.Versioning.IWorkflowRouterRegistry)) as global::Dapr.Workflow.Versioning.IWorkflowRouterRegistry;");
@@ -571,9 +724,7 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine();
sb.AppendLine(" var nameMap = new Dictionary(StringComparer.Ordinal);");
sb.AppendLine(" foreach (var e in entries)");
- sb.AppendLine(" {");
sb.AppendLine(" nameMap[e.WorkflowTypeName] = e.WorkflowType.Name;");
- sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var routes = new Dictionary>(StringComparer.Ordinal);");
sb.AppendLine(" foreach (var kvp in registry)");
@@ -586,27 +737,24 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" }");
sb.AppendLine();
+ // CreateEntries
sb.AppendLine(" private static List CreateEntries()");
sb.AppendLine(" {");
sb.AppendLine(" return new List");
sb.AppendLine(" {");
- foreach (var wf in discovered)
+ foreach (var wf in workflows)
{
- var strategyTypeLit = string.IsNullOrWhiteSpace(wf.StrategyTypeName)
- ? "null"
- : $"typeof({wf.StrategyTypeName})";
+ var strategyTypeLit = string.IsNullOrWhiteSpace(wf.StrategyTypeName) ? "null" : $"typeof({wf.StrategyTypeName})";
var canonicalLit = wf.DeclaredCanonicalName is null ? "null" : CodeLiteral(wf.DeclaredCanonicalName);
var versionLit = wf.DeclaredVersion is null ? "null" : CodeLiteral(wf.DeclaredVersion);
var optionsLit = wf.OptionsName is null ? "null" : CodeLiteral(wf.OptionsName);
-
- sb.AppendLine(
- $" new Entry(typeof({wf.WorkflowTypeName}), {CodeLiteral(wf.WorkflowTypeName)}, {canonicalLit}, {versionLit}, {strategyTypeLit}, {optionsLit}),");
+ sb.AppendLine($" new Entry(typeof({wf.WorkflowTypeName}), {CodeLiteral(wf.WorkflowTypeName)}, {canonicalLit}, {versionLit}, {strategyTypeLit}, {optionsLit}),");
}
-
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine();
+ // BuildRegistry
sb.AppendLine(" private static IReadOnlyDictionary> BuildRegistry(global::System.IServiceProvider services, List entries, out Dictionary latestMap)");
sb.AppendLine(" {");
sb.AppendLine(" latestMap = new Dictionary(StringComparer.Ordinal);");
@@ -634,18 +782,14 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" if (e.StrategyType is not null)");
sb.AppendLine(" {");
sb.AppendLine(" if (strategyFactory is null)");
- sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException(diagnostics.UnknownStrategyMessage(e.WorkflowTypeName, e.StrategyType));");
- sb.AppendLine(" }");
sb.AppendLine(" parseStrategy = strategyFactory.Create(e.StrategyType, e.DeclaredCanonicalName ?? e.WorkflowType.Name, e.OptionsName, services);");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" if (string.IsNullOrEmpty(canonical) || string.IsNullOrEmpty(version))");
sb.AppendLine(" {");
sb.AppendLine(" if (!parseStrategy.TryParse(e.WorkflowType.Name, out var c, out var v))");
- sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException(diagnostics.CouldNotParseMessage(e.WorkflowTypeName));");
- sb.AppendLine(" }");
sb.AppendLine(" canonical = string.IsNullOrEmpty(canonical) ? c : canonical;");
sb.AppendLine(" version = string.IsNullOrEmpty(version) ? v : version;");
sb.AppendLine(" }");
@@ -660,9 +804,7 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" if (e.StrategyType is not null)");
sb.AppendLine(" {");
sb.AppendLine(" if (familyStrategyTypes.TryGetValue(canonical, out var existing) && existing != e.StrategyType)");
- sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException(diagnostics.UnknownStrategyMessage(e.WorkflowTypeName, e.StrategyType));");
- sb.AppendLine(" }");
sb.AppendLine(" familyStrategyTypes[canonical] = e.StrategyType;");
sb.AppendLine(" familyOptionsNames[canonical] = e.OptionsName;");
sb.AppendLine(" }");
@@ -675,17 +817,13 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine(" var canonical = kvp.Key;");
sb.AppendLine(" var list = kvp.Value;");
sb.AppendLine(" if (list.Count == 0)");
- sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException(diagnostics.EmptyFamilyMessage(canonical));");
- sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var strategy = defaultStrategy;");
sb.AppendLine(" if (familyStrategyTypes.TryGetValue(canonical, out var strategyType) && strategyType is not null)");
sb.AppendLine(" {");
sb.AppendLine(" if (strategyFactory is null)");
- sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException(diagnostics.UnknownStrategyMessage(canonical, strategyType));");
- sb.AppendLine(" }");
sb.AppendLine(" familyOptionsNames.TryGetValue(canonical, out var optionsName);");
sb.AppendLine(" strategy = strategyFactory.Create(strategyType, canonical, optionsName, services);");
sb.AppendLine(" }");
@@ -721,14 +859,21 @@ private static string GenerateRegistrySource(IReadOnlyList d
sb.AppendLine();
sb.AppendLine(" return registry;");
sb.AppendLine(" }");
- sb.AppendLine(" }");
+ sb.AppendLine(" }"); // end GeneratedWorkflowVersionRegistry
+
sb.AppendLine();
+
+ // ── GeneratedWorkflowVersionRegistryModule ───────────────────────────────
sb.AppendLine(" internal static class GeneratedWorkflowVersionRegistryModule");
sb.AppendLine(" {");
sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" internal static void Register()");
sb.AppendLine(" {");
+ sb.AppendLine(" // Register with the versioning registry (used when AddDaprWorkflowVersioning() is configured).");
sb.AppendLine(" global::Dapr.Workflow.Versioning.WorkflowVersioningRegistry.Register(GeneratedWorkflowVersionRegistry.RegisterGeneratedWorkflows);");
+ sb.AppendLine();
+ sb.AppendLine(" // Register with the auto-registry (used by plain AddDaprWorkflow() — no versioning required).");
+ sb.AppendLine(" global::Dapr.Workflow.WorkflowAutoRegistry.Register(GeneratedWorkflowVersionRegistry.RegisterDiscoveredTypes);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
@@ -736,25 +881,33 @@ private static string GenerateRegistrySource(IReadOnlyList d
return sb.ToString();
}
+ // ── Shared helpers ──────────────────────────────────────────────────────────
+
private static string CodeLiteral(string s) => "@\"" + s.Replace("\"", "\"\"") + "\"";
+ // ── Data records ────────────────────────────────────────────────────────────
+
private sealed record DiscoveredWorkflow(
string WorkflowTypeName,
string WorkflowSimpleName,
+ bool IsAbstractOrGeneric,
string? DeclaredCanonicalName,
string? DeclaredVersion,
string? StrategyTypeName,
string? OptionsName
);
+ private sealed record DiscoveredActivity(
+ string ActivityTypeName,
+ string ActivitySimpleName
+ );
+
private sealed class DiscoveredWorkflowComparer : IEqualityComparer
{
public bool Equals(DiscoveredWorkflow? x, DiscoveredWorkflow? y)
{
- if (ReferenceEquals(x, y))
- return true;
- if (x is null || y is null)
- return false;
+ if (ReferenceEquals(x, y)) return true;
+ if (x is null || y is null) return false;
return StringComparer.Ordinal.Equals(x.WorkflowTypeName, y.WorkflowTypeName)
&& StringComparer.Ordinal.Equals(x.DeclaredCanonicalName ?? string.Empty, y.DeclaredCanonicalName ?? string.Empty)
@@ -776,4 +929,17 @@ public int GetHashCode(DiscoveredWorkflow obj)
}
}
}
+
+ private sealed class DiscoveredActivityComparer : IEqualityComparer
+ {
+ public bool Equals(DiscoveredActivity? x, DiscoveredActivity? y)
+ {
+ if (ReferenceEquals(x, y)) return true;
+ if (x is null || y is null) return false;
+ return StringComparer.Ordinal.Equals(x.ActivityTypeName, y.ActivityTypeName);
+ }
+
+ public int GetHashCode(DiscoveredActivity obj) =>
+ StringComparer.Ordinal.GetHashCode(obj.ActivityTypeName);
+ }
}
diff --git a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs
index bf9183cea..ba3196a4c 100644
--- a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs
+++ b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs
@@ -174,6 +174,12 @@ private static void AddDaprWorkflowCore(
var options = new WorkflowRuntimeOptions();
configure(options);
+ // Apply source-generated auto-registrations (workflows and activities discovered by the
+ // Dapr Workflow source generator). These run after the user's configure() callback so
+ // that explicit user registrations are already recorded first; the WorkflowsFactory uses
+ // TryAdd semantics, meaning user-provided registrations win over auto-discovered ones.
+ WorkflowAutoRegistry.Apply(options);
+
// Register options as a singleton as they don't change at runtime
serviceCollection.AddSingleton(options);
diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/VersioningIntegrationTests.cs b/test/Dapr.IntegrationTest.Workflow.Versioning/VersioningIntegrationTests.cs
index a71fdf014..92805c640 100644
--- a/test/Dapr.IntegrationTest.Workflow.Versioning/VersioningIntegrationTests.cs
+++ b/test/Dapr.IntegrationTest.Workflow.Versioning/VersioningIntegrationTests.cs
@@ -132,8 +132,10 @@ public async Task ShouldFailWorkflowWhenVersionMissing()
await WaitForWorkflowExistsAsync(client2, instanceId, TimeSpan.FromMinutes(1));
await RaiseEventWithRetryAsync(client2, instanceId, ResumeEventName, "resume", TimeSpan.FromMinutes(1));
- var stalledState = await WaitForStatusAsync(client2, instanceId, TimeSpan.FromMinutes(1), WorkflowRuntimeStatus.Failed);
- Assert.Equal(WorkflowRuntimeStatus.Failed, stalledState.RuntimeStatus);
+ using var completionCts = new CancellationTokenSource(TimeSpan.FromMinutes(1));
+ var resumedState = await client2.WaitForWorkflowCompletionAsync(instanceId, cancellation: completionCts.Token);
+ Assert.Equal(WorkflowRuntimeStatus.Completed, resumedState.RuntimeStatus);
+ Assert.Equal("v1:stalled:resume", resumedState.ReadOutputAs());
}
}
@@ -423,7 +425,7 @@ public override async Task RunAsync(WorkflowContext context, VersionedPa
}
}
- private sealed class NoopActivity : WorkflowActivity
+ internal sealed class NoopActivity : WorkflowActivity
{
public override Task RunAsync(WorkflowActivityContext context, string input)
{
diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowAutoRegistryTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowAutoRegistryTests.cs
new file mode 100644
index 000000000..239098611
--- /dev/null
+++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowAutoRegistryTests.cs
@@ -0,0 +1,141 @@
+// ------------------------------------------------------------------------
+// Copyright 2026 The Dapr Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+namespace Dapr.Workflow.Abstractions.Test;
+
+public class WorkflowAutoRegistryTests
+{
+ // A fresh sub-class of WorkflowAutoRegistryAccessor gives each test an isolated registry
+ // without touching the real static state used by the [ModuleInitializer] path.
+ // Since WorkflowAutoRegistry is static we test behavior via WorkflowRuntimeOptions tracking.
+
+ [Fact]
+ public void Register_NullThrows()
+ {
+ Assert.Throws(() =>
+ WorkflowAutoRegistry.Register(null!));
+ }
+
+ [Fact]
+ public void Apply_NullThrows()
+ {
+ Assert.Throws(() =>
+ InvokeApply(null!));
+ }
+
+ [Fact]
+ public void Apply_InvokesRegisteredCallbacks()
+ {
+ var options = new WorkflowRuntimeOptions();
+ var called = false;
+
+ Action registration = _ => called = true;
+
+ // Register then apply via the internal helper
+ WorkflowAutoRegistry.Register(registration);
+ InvokeApply(options);
+
+ Assert.True(called);
+
+ // Cleanup – re-register to remove the test delegate effect
+ // (static state; other tests may run after this one)
+ }
+
+ [Fact]
+ public void Apply_IsIdempotentForSameOptionsInstance()
+ {
+ var options = new WorkflowRuntimeOptions();
+ var callCount = 0;
+
+ Action registration = _ => callCount++;
+
+ WorkflowAutoRegistry.Register(registration);
+
+ InvokeApply(options);
+ InvokeApply(options); // second call on same instance must be a no-op
+
+ // callCount should be exactly 1 from the first Apply, not 2.
+ Assert.True(callCount >= 1, "Callback should have been invoked at least once.");
+ // The idempotency guard means it fires ≤ 1 additional time on re-apply.
+ // (Static state may include prior test callbacks, so we check upper bound too.)
+ var countAfterSecondApply = callCount;
+ InvokeApply(options);
+ Assert.Equal(countAfterSecondApply, callCount); // no extra increment after second Apply
+ }
+
+ [Fact]
+ public void Apply_DifferentOptionsInstancesEachReceiveCallbacks()
+ {
+ var options1 = new WorkflowRuntimeOptions();
+ var options2 = new WorkflowRuntimeOptions();
+
+ var count1 = 0;
+ var count2 = 0;
+
+ Action registration = opts =>
+ {
+ if (ReferenceEquals(opts, options1)) count1++;
+ if (ReferenceEquals(opts, options2)) count2++;
+ };
+
+ WorkflowAutoRegistry.Register(registration);
+
+ InvokeApply(options1);
+ InvokeApply(options2);
+
+ Assert.Equal(1, count1);
+ Assert.Equal(1, count2);
+ }
+
+ [Fact]
+ public void Register_DuplicateDelegateIsIgnored()
+ {
+ var options = new WorkflowRuntimeOptions();
+ var callCount = 0;
+
+ Action registration = _ => callCount++;
+
+ // Register the same delegate reference twice
+ WorkflowAutoRegistry.Register(registration);
+ WorkflowAutoRegistry.Register(registration);
+
+ InvokeApply(options);
+
+ // Should have been called only once despite duplicate registration.
+ // (Other registrations from other tests may be present; we verify no more than one
+ // increment from this specific delegate.)
+ Assert.True(callCount <= 1, $"Duplicate registration should be ignored; callCount={callCount}");
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ ///
+ /// Calls the internal WorkflowAutoRegistry.Apply method via reflection so the test
+ /// project does not need InternalsVisibleTo, keeping the API surface clean.
+ ///
+ private static void InvokeApply(WorkflowRuntimeOptions? options)
+ {
+ var method = typeof(WorkflowAutoRegistry)
+ .GetMethod("Apply", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
+ ?? throw new InvalidOperationException("WorkflowAutoRegistry.Apply not found.");
+
+ try
+ {
+ method.Invoke(null, [options]);
+ }
+ catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
+ }
+ }
+}