Testing

webTiger Logo Wide

Adding Visual Debugging to a Re-Hosted Workflow Designer

Workflow

The basic re-hosted workflow designer solution described in this article provides a baseline for a bespoke application that can be used for designing workflows, but without some further enhancements it is quite limited. A relatively straightforward addition to a re-hosted workflow designer is visual debugging.

To achieve this we need to register a tracking participant with the workflow execution and then link that back to the debug-view that is already provided by the System.Activities.Presentation.WorkflowDesigner class.

There are a few other hurdles to jump before we can get the solution up and running though, and we’ll discuss them as they occur.

This worked example will build upon the basic application discussed in the re-hosting the workflow designer article. If you are going build the solution as you go you should probably start by following the basic re-hosting article and creating the baseline application as a starting point. Once you are ready to continue, you can come back here!

First, let’s add a few buttons to control debugging in the main window. Add the following after the existing New/Open/Save button definitions in the MainWindow XAML markup file within the same stack panel container. (The label is just a lazy way of providing a spacer between file-based buttons and debug-based ones.)

<Label Content=" " />
<Button Content=" Debug (Normal) " Click="DebugButton_Click" Margin="5,5,5,5"/>
<Button Content=" Debug (Slow Motion) " Click="DebugSlowMoButton_Click" Margin="5,5,5,5"/>
<Button Content=" Abort " Click="AbortButton_Click" Margin="5,5,5,5"/>Code language: HTML, XML (xml)

Add the associated event handlers in the code behind (we’ll add the content for them later):

private void DebugButton_Click(object sender, RoutedEventArgs e) { }
private void DebugSlowMoButton_Click(object sender, RoutedEventArgs e) { }
private void AbortButton_Click(object sender, RoutedEventArgs e) { }Code language: C# (cs)

Next, let’s define a general purpose tracking participant that will be used to feed workflow execution progress back to the workflow designer. The main points to note are that we want to track everything by default (which is set up in the GetTrackAllProfile() method) and that the UI needs to subscribe to the RecordReceived event to update the visual debugging indicator:

using System.Activities;
using System.Activities.Debugger;
using System.Activities.Tracking;
using System.Collections.Generic;

class GeneralTrackingParticipant : TrackingParticipant
{
    /// <summary>
    /// Specifies the signature of the event handler for tracking record 
    /// received events.
    /// </summary>
    /// <param name="sender">The object from which the event initiated.</param>
    /// <param name="e">The object that describes the event data.</param>
    public delegate void RecordReceivedEventHandler(
        object sender, RecordReceivedEventArgs e);

    /// <summary>
    /// Occurs when a tracking record is received by the tracking participant.
    /// </summary>
    public event RecordReceivedEventHandler RecordReceived;

    /// <summary>
    /// Provides properties and methods that describe the tracking record that 
    /// was received.
    /// </summary>
    public sealed class RecordReceivedEventArgs : EventArgs
    {
        /// <summary>
        /// Creates a new instance of the <see cref="RecordReceivedEventArgs" /> class.
        /// </summary>
        public RecordReceivedEventArgs()
            : this(null, null)
        {
        }

        /// <summary>
        /// Creates a new instance of the <see cref="RecordReceivedEventArgs" /> class.
        /// </summary>
        /// <param name="record">The tracking record details.</param>
        public RecordReceivedEventArgs(TrackingRecord record)
            : this(record, null)
        {
        }

        /// <summary>
        /// Creates a new instance of the <see cref="RecordReceivedEventArgs" /> class.
        /// </summary>
        /// <param name="record">The tracking record details.</param>
        /// <param name="debugLocation">
        /// The source location to set as the current location.
        /// </param>
        public RecordReceivedEventArgs(
            TrackingRecord record, SourceLocation debugLocation)
        {
            this.Record = record;
            this.Debug = debugLocation;
        }

        /// <summary>
        /// Gets or sets the source location that represents the current location.
        /// </summary>
        public SourceLocation Debug { get; set; }

        /// <summary>
        /// Gets or sets the tracking record details.
        /// </summary>
        public TrackingRecord Record { get; set; }
    }

    /// <summary>
    /// Creates a new instance of the <see cref="GeneralTrackingParticipant" /> class.
    /// </summary>
    public GeneralTrackingParticipant()
    {
        this.TrackingProfile = this.GetTrackAllProfile();
    }

    /// <summary>
    /// Gets or sets a collection of the activity-to-source-location mappings 
    /// for a workflow.
    /// </summary>
    public Dictionary<object, SourceLocation> SourceLocations { get; set; }

    /// <summary>
    /// When implemented in a derived class, used to synchronously process the 
    /// tracking record.
    /// </summary>
    /// <param name="record">The generated tracking record.</param>
    /// <param name="timeout">
    /// The time period after which the provider aborts the attempt.
    /// </param>
    protected override void Track(TrackingRecord record, TimeSpan timeout)
    {
        this.OnRecordReceived(record);
    }

    private TrackingProfile GetTrackAllProfile()
    {
        TrackingProfile profile = new TrackingProfile
        {
            Name = "CustomGeneralTrackingProfile",
            ImplementationVisibility = ImplementationVisibility.All
        };

        WorkflowInstanceQuery instanceQuery = new WorkflowInstanceQuery();
        instanceQuery.States.Add(WorkflowInstanceStates.Aborted);
        instanceQuery.States.Add(WorkflowInstanceStates.Started);
        instanceQuery.States.Add(WorkflowInstanceStates.Completed);
        instanceQuery.States.Add(WorkflowInstanceStates.Suspended);
        instanceQuery.States.Add(WorkflowInstanceStates.Terminated);
        instanceQuery.States.Add(WorkflowInstanceStates.UnhandledException);

        profile.Queries.Add(instanceQuery);

        profile.Queries.Add(new ActivityStateQuery 
        { 
            ActivityName = "*",
            States = 
            { 
                ActivityStates.Canceled, 
                ActivityStates.Closed,
                ActivityStates.Executing, 
                ActivityStates.Faulted 
            },
            Variables = { "*" }
        });

        profile.Queries.Add(new CancelRequestedQuery 
        {
            ActivityName = "*",
            ChildActivityName = "*"
        });

        profile.Queries.Add(new CustomTrackingQuery 
        {
            ActivityName = "*",
            Name = "*"
        });

        profile.Queries.Add(new FaultPropagationQuery 
        {
            FaultSourceActivityName = "*",
            FaultHandlerActivityName = "*"
        });

        profile.Queries.Add(new BookmarkResumptionQuery { Name = "*" });

        profile.Queries.Add(new ActivityScheduledQuery 
        {
            ActivityName = "*",
            ChildActivityName = "*"
        });

        return profile;
    }

    /// <summary>
    /// Returns the activity associated with the tracking record, or a null.
    /// </summary>
    /// <param name="record">The record to parse.</param>
    /// <returns>An activity, or a null.</returns>
    private ActivityInfo GetActivity(TrackingRecord record)
    {
        ActivityStateRecord activityState = record as ActivityStateRecord;

        if (activityState != null)
        {
            return activityState.Activity;
        }

        return null;
    }

    /// <summary>
    /// Raises the RecordReceived event using the supplied tracking record details.
    /// </summary>
    /// <param name="record">The tracking record details.</param>
    private void OnRecordReceived(TrackingRecord record)
    {
        if (this.RecordReceived != null)
        {
            if (this.SourceLocations != null && this.SourceLocations.Count > 0)
            {
                // Attempt to provide the source-location info to assist with 
                // visual debugging.
                ActivityInfo tracked = this.GetActivity(record);

                if (tracked != null)
                {
                    Activity current;

                    foreach (object key in this.SourceLocations.Keys)
                    {
                        current = key as Activity;

                        if (current != null && current.Id == tracked.Id)
                        {
                            this.RecordReceived.Invoke(this, 
                                new RecordReceivedEventArgs(
                                    record, this.SourceLocations[key]));
                            return;
                        }
                    }
                }
            }

            // Fall-back to a record received event without any 
            // debugging information in all other cases!
            this.RecordReceived.Invoke(this, new RecordReceivedEventArgs(record));
        }
    }
}Code language: C# (cs)

The debugging view in the workflow designer control works on XAML line number, so we need to provide some kind of line-number to visual layout cross-referencing lookup. The Workflow architecture refers to the line-number as ‘source location’ so we will be building an activity to source-location key-value pairs collection so we can highlight the correct activity in our designer control.

NOTE: There is a method for this in the Framework’s Workflow architecture already – the System.Activities.Debugger.SourceLocationProvider.CollectMapping method. The problem with using it with this editor approach is that it doesn’t support the System.Activities.ActivityBuilder or System.Activities.DynamicActivity classes, and it won’t return a valid set of mappings in those scenarios. We are therefore defining our own implementation for building the mappings.

The GetSourceLocationMappings method (below) also has another advantage over the built-in mapping method, as it also supports state-machine based workflows (which the CollectMapping method does not!) The method could be extended to include variables, etc. if required, but we are keeping things simple for now.

First, add some more using directives to the top of the file:

using System.Activities.Debugger;
using System.Activities.Presentation.Model;
using System.Activities.Presentation.Services;
using System.Activities.Presentation.View;
using System.Activities.XamlIntegration;
using System.IO;
using System.Reflection;Code language: C# (cs)

Then, add the code to generated the source mappings:

/// <summary>
// Builds a collection of activity to source-location mappings for the 
/// supplied workflow.
/// </summary>
/// <param name="workflow">The root activity in the workflow.</param>
/// <returns>A collection of mappings.</returns>
private Dictionary<object, SourceLocation> GetSourceLocationMappings(
    DynamicActivity workflow)
{
    Dictionary<object, SourceLocation> mappings = 
        new Dictionary<object, SourceLocation>();

    // Retrieve a list of all the activities and states in the current workflow.
    ModelService modelService = 
        this._editor.Context.Services.GetService<ModelService>();
    List<ModelItem> elements = 
        new List<ModelItem>(modelService.Find(modelService.Root, typeof(Activity)));
    elements.AddRange(modelService.Find(modelService.Root,
    typeof(System.Activities.Statements.State)));

    // Process the elements, creating the activity/state to source-location mappings.
    foreach (ModelItem item in elements)
    {
        Activity activity = item.GetCurrentValue() as Activity;

        if (activity == null)
        {
            System.Activities.Statements.State state =
                item.GetCurrentValue() as System.Activities.Statements.State;
            PropertyInfo property =
                typeof(System.Activities.Statements.State)
                    .GetProperty(
                        "InternalState",
                        BindingFlags.Instance | BindingFlags.NonPublic);

            if (property != null)
            {
                activity = property.GetValue(state) as Activity;
            }
        }

        if (activity != null && !mappings.ContainsKey(activity))
        {
            // Select the activity in the editor, attempt to retrieve it's location
            // (equivalent XAML line mapping), and then de-select it.
            Selection.SelectOnly(this._editor.Context, item);

            if (this._editor.DebugManagerView.SelectedLocation != null)
            {
                mappings.Add(activity, this._editor.DebugManagerView.SelectedLocation);
            }

            Selection.Toggle(this._editor.Context, item);
        }
    }

    return mappings;
}Code language: C# (cs)

We then need to define an event handler to receive our tracking records in the main window code behind file.

private int _debugStepDelay = 0;

private void DebugTrackingParticipant_RecordReceived(
    object sender, GeneralTrackingParticipant.RecordReceivedEventArgs e)
{
    if (e.Debug != null)
    {
        this.Dispatcher.Invoke(delegate 
        {
            // Update the location in the debug view.
            this._editor.DebugManagerView.CurrentLocation = e.Debug;
            this._editor.DebugManagerView.EnsureVisible(e.Debug);
        });

        if (this.debugStepDelay > 0)
        {
            // Add some waiting time so the execution flow can be observed 
            // when debugging!
            System.Threading.Thread.Sleep(this._debugStepDelay);
        }
    }
}Code language: C# (cs)

That’s all we need to get our visual debugging functionality working. Let’s put the code in place to handle the various button-click events that will run and abort the workflow being edited.

private WorkflowApplication _runner;

private void DebugButton_Click(object sender, RoutedEventArgs e)
{
    this._debugStepDelay = 0;
    this.RunDebug();
}

private void DebugSlowMoButton_Click(object sender, RoutedEventArgs e)
{
    this._debugStepDelay = 500;
    this.RunDebug();
}

private void AbortButton_Click(object sender, RoutedEventArgs e)
{
    this.runner.Abort();
}

private void RunDebug()
{
    // Make sure all changes are flushed to designer's Text property.
    this._editor.Flush(); 

    GeneralTrackingParticipant debugTracker = new GeneralTrackingParticipant();
    debugTracker.RecordReceived += this.DebugTrackingParticipant_RecordReceived;

    // Get the root activity being executed.
    DynamicActivity rootActivity;

    using (MemoryStream data = 
        new MemoryStream(Encoding.Default.GetBytes(this._editor.Text)))
    {
        rootActivity = ActivityXamlServices.Load(data) as DynamicActivity;
    }

    if (rootActivity == null)
    {
        MessageBox.Show(
            "The root activity for the workflow could not be detected. " +
            "An ActivityBuilder or DynamicActivity object is expected at the root.");
        return;
    }

    // Create the source-location mappings for the workflow being executed.
    WorkflowInspectionServices.CacheMetadata(rootActivity);
    this._editor.DebugManagerView.IsDebugging = true;
    debugTracker.SourceLocations = this.GetSourceLocationMappings(rootActivity);

    // Initialise a new workflow application and run in a background thread...
    this.runner = new WorkflowApplication(rootActivity);
    this.runner.Extensions.Add(debugTracker);
    this.runner.Aborted = this.WorkflowApplication_Aborted;
    this.runner.Completed = this.WorkflowApplication_Completed;

    BackgroundWorker worker = new BackgroundWorker();
    worker.DoWork += delegate { this.runner.Run(); };
    worker.RunWorkerAsync();
}Code language: C# (cs)

All done. We’ve now got a re-hosted workflow designer that we can load and save workflow files in, and can visually track workflow execution process.