With TFS Build 2010 (Beta 1), we've changed the build orchestration language from MSBuild to Windows Workflow Foundation (WF).  As such, you now have a new option for adding custom logic to your build proces - custom WF activities.  I've gotten a couple of requests for a blog post with an example, best practices, and so forth, so here goes.

There are four ways (that I know of, at least) to author a custom WF activity - using the WF Designer / XML Editor and composing your custom activity in XAML; composing your custom activity in code; writing a custom CodeActivity; or writing a custom NativeActivity.  This list is more or lest organized by degree of difficulty, and more or less by preference as well. 

The first two approaches involve building a new activity up from existing activities - when possible, this is a good strategy, since it has several advantages over writing custom code to do your work:

  1. Composed activities obviously re-use code to a high degree, which is always a good idea - why reinvent the wheel if you don't have to?
  2. Composed activities are by their nature cancellable by the workflow runtime, meaning that builds running your custom activity will be easy to stop cleanly.
  3. Composed activities can participate in workflow tracking, meaning that their internal progress can be easily tracked as they execute.  (If you don't want their progress to be tracked when running in a build, you can turn this behavior off using Microsoft.TeamFoundation.Build.Workflow.Tracking.ActivityTrackingAttribute.)
  4. It's comparatively easy, at least once you get the hang of it.

I'll be tackling these approaches in this post, and the CodeActivity and NativeActivity approaches in a subsequent post.  The activity I'll be writing takes in a server path to a script file and a Workspace object (along with a couple of other optional parameters), executes the script, and returns the exit code.  The assumption, then, is that this activity will be used in a build process at some point after the workspace for the build has been set up and after that workspace has been synched.  Note that a big difference between MSBuild and WF is that WF can hand actual objects around between activities, as opposed to MSBuild which basically just hands strings around. 

To write my activity, I created a new WF ActivityLibrary project, added references to Microsoft.TeamFoundation.Build.Client, Microsoft.TeamFoundation.Build.Common, Microsoft.TeamFoundation.Build.Workflow, Microsoft.TeamFoundation.VersionControl.Client, and Microsoft.TeamFoundation.VersionControl.Common.  I then added the various arguments I wanted my activity to have using the Arguments flyout in the WF designer, dragged and dropped a Sequence activity and the two TFS Build activities I needed, set the appropriate properties, etc.  Here's what it looks like in the Beta 1 WF Designer:

InvokeScript

And here's the XAML:

<p:Activity mc:Ignorable="" x:Class="ActivityLibrary1.InvokeScript" xmlns="http://schemas.microsoft.com/netfx/2009/xaml/activities/design" xmlns:__InvokeScript="clr-namespace:ActivityLibrary1;" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mtbwa="clr-namespace:Microsoft.TeamFoundation.Build.Workflow.Activities;assembly=Microsoft.TeamFoundation.Build.Workflow" xmlns:mtvc="clr-namespace:Microsoft.TeamFoundation.VersionControl.Client;assembly=Microsoft.TeamFoundation.VersionControl.Client" xmlns:p="http://schemas.microsoft.com/netfx/2009/xaml/activities" xmlns:sad="clr-namespace:System.Activities.Debugger;assembly=System.Activities" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <x:Members>
    <x:Property Name="Arguments" Type="p:InArgument(x:String)" />
    <x:Property Name="Script" Type="p:InArgument(x:String)" />
    <x:Property Name="WorkingDirectory" Type="p:InArgument(x:String)" />
    <x:Property Name="Workspace" Type="p:InArgument(mtvc:Workspace)" />
    <x:Property Name="ExitCode" Type="p:OutArgument(x:Int32)" />
  </x:Members>
  <p:Sequence DisplayName="Invoke Script From Source Control">
    <p:Sequence.Variables>
      <p:Variable x:TypeArguments="x:String" Name="localScriptPath" />
    </p:Sequence.Variables>
    <mtbwa:ConvertWorkspaceItem DisplayName="Convert Server Path to Local Path" Input="[Script]" Result="[localScriptPath]" Workspace="[Workspace]" />
    <mtbwa:InvokeProcess Arguments="[String.Format(&quot;/c &quot;&quot;{0}&quot;&quot; {1}&quot;, localScriptPath, Arguments)]" DisplayName="Invoke the Script" FileName="[&quot;cmd.exe&quot;]" Result="[ExitCode]" WorkingDirectory="[WorkingDirectory]">
      <mtbwa:InvokeProcess.BeforeExecute>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="commandLine" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildMessage Message="[commandLine]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.BeforeExecute>
      <mtbwa:InvokeProcess.ErrorDataReceived>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="errorData" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildError Message="[errorData]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.ErrorDataReceived>
      <mtbwa:InvokeProcess.OutputDataReceived>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="outputData" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildMessage Importance="Normal" Message="[outputData]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.OutputDataReceived>
    </mtbwa:InvokeProcess>
  </p:Sequence>
</p:Activity>

You might notice that the various ActivityActions in the XAML (which handle writing the command-line and standard output to the build activity log as messages, and standard error as errors) are not present in the designer - we didn't get around to implementing a custom designer for the InvokeProcess activity for Beta 1, so for this activity I had to "drop to XAML" and edit the XML directly.  Normally this shouldn't be required - for the most part you should be able to fully author your custom activities in the WF Designer.  When my activity library project is built, this XAML will be compiled into an activity named InvokeScript (the name comes from the x:Class attribute on the root).

As mentioned above, activities can be composed either in xaml or in code.  To write a composed activity in code, you'll derive from System.Activities.Activity, or System.Activities.Activity<T>, add whatever arguments and so forth you need, and then override the CreateBody method to return your composed implementation.  Here's a second version of my InvokeScript activity, written in C# code:

using System;
using System.Activities;
using System.Activities.Statements;
using System.ComponentModel;
using Microsoft.TeamFoundation.Build.Workflow.Activities;
using Microsoft.TeamFoundation.VersionControl.Client;

namespace ActivityLibrary1
{
    public sealed class InvokeScript2 : Activity<Int32>
    {
        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> Arguments { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> Script { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> WorkingDirectory { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<Workspace> Workspace { get; set; }

        protected override WorkflowElement CreateBody()
        {
            Variable<String> localScriptPath = new Variable<String>();
            Variable<String> commandLine = new Variable<String>();
            Variable<String> outputData = new Variable<String>();
            Variable<String> errorData = new Variable<String>();

            return new Sequence
            {
                Variables = 
                {
                    localScriptPath
                },
                Activities =
                {
                    new ConvertWorkspaceItem
                    {
                        Input = new InArgument<String>(env => this.Script.Get(env)),
                        Workspace = new InArgument<Workspace>(env => this.Workspace.Get(env)),
                        Result = localScriptPath
                    },
                    new InvokeProcess
                    {
                        FileName = new InArgument<String>(env => "cmd.exe"),
                        Arguments = new InArgument<String>(env => String.Format("/c \"{0}\" {1}", localScriptPath.Get(env), Arguments.Get(env))),
                        WorkingDirectory = new InArgument<String>(env => this.WorkingDirectory.Get(env)),
                        Result = new OutArgument<int>(env => this.Result.Get(env)),
                        BeforeExecute = new ActivityAction<String>
                        {
                            Argument = commandLine,
                            Handler = new WriteBuildMessage
                            {
                                Message = commandLine
                            },
                        },
                        OutputDataReceived = new ActivityAction<String>
                        {
                            Argument = outputData,
                            Handler = new WriteBuildMessage
                            {
                                Message = outputData
                            },
                        },
                        ErrorDataReceived = new ActivityAction<String>
                        {
                            Argument = errorData,
                            Handler = new WriteBuildError
                            {
                                Message = errorData
                            },
                        },
                    },
                },
            };
        }
    }
}

The primary difference between this version and the XAML version is that here I've elevated the single output argument (ExitCode) to the return value of the activity - hence it derives from Activity<Int32>.  The metaphor for activities is methods and functions - when you have a single output, the standard practice is to elevate it to the return value rather than using an OutArgument (but this is not, so far as I know, possible when writing an activity in XAML).