Advanced activities

Durable activities that wait for external events, container activities with child slots, composite activities, compile-time validators and run services.

Most activities are a CodeActivity or an AsyncCodeActivity. This page covers the rest of what Velophex.Workflow.Sdk offers. Wait for an external event# A DurableActivity can pause the workflow until something happens outside it, for minutes or days, without holding a Robot. While it waits, the workflow is saved and the job is Suspended; when the wait ends, a Robot restores it and your activity continues. WaitForInvoiceApprovalActivity.csC#Copyusing Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Metadata; namespace Contoso.Invoices.Activities; [ActivityType("contoso.invoices.waitForApproval")] [ActivityInfo( DisplayName = "Wait for Invoice Approval", Category = "Invoices", Description = "Suspends the workflow until the invoice is approved or the wait expires.", IconKey = "InvoiceApproval")] [ActivityCharacteristics(Idempotent = true, RetrySafe = true, Risk = ActivityRisk.None)] public sealed class WaitForInvoiceApprovalActivity : DurableActivity { [RequiredArgument] [ArgumentInfo(DisplayName = "Invoice number", Order = 0)] public InArgument<string> InvoiceNumber { get; set; } = new(); [ArgumentInfo(DisplayName = "Wait (hours)", Order = 1, DefaultValue = 24)] public InArgument<int> WaitHours { get; set; } = new(); [ArgumentInfo(DisplayName = "Approved", Order = 10)] public OutArgument<bool> Approved { get; set; } = new(); protected override ValueTask ExecuteAsync( DurableActivityContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); int hours = context.GetValue(WaitHours); // A bookmark name is an identifier, never data: letters, digits and . _ - : / context.CreateBookmark( "invoice-approval", new ActivityBookmarkOptions { PayloadType = typeof(bool), ExpiresAfter = TimeSpan.FromHours(hours > 0 ? hours : 24) }); return ValueTask.CompletedTask; } protected override ValueTask OnResumeAsync( DurableActivityContext context, string bookmarkName, object? payload, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); // Returning without a new bookmark completes the activity. context.SetValue(Approved, !context.BookmarkExpired && payload is true); return ValueTask.CompletedTask; } } How it runs: ExecuteAsync runs once. If it creates one or more bookmarks, the workflow waits; if it creates none, the activity completes like any other. The activity's inputs, any outputs already set and the bookmarks are saved. No thread or process is held. When the bookmark is resumed or expires, OnResumeAsync runs on a new object, possibly on another machine, with the same inputs and the delivered payload. Don't rely on fields or live objects from step 1. The activity completes when a call returns without leaving an active bookmark. ActivityBookmarkOptions: Option Effect PayloadType The type the resume payload must have. A JSON payload is converted; a mismatch is refused before anything changes. ExpiresAfter Ends the wait after this long: OnResumeAsync runs with context.BookmarkExpired set to true and no payload. Reusable Keeps the bookmark after each resume, so the activity can receive several events. End the wait with context.RemoveBookmark(name). CorrelationKey An optional key a host can resume by instead of the name. It must not contain secrets. Resume a waiting job. On the Orchestrator, a suspended job lists what it waits for. Someone with permission to start jobs can resume it from the Jobs page (Resume: wait name, see Jobs), or through the API with the bookmark name and a JSON value: HTTPCopyPOST /api/v1/tenants/{tenantId}/workspaces/{workspaceId}/jobs/{jobId}/resume Content-Type: application/json { "bookmark": "invoice-approval", "value": true } Things to know: Only a DurableActivity may create bookmarks. Any other activity that tries faults the workflow. A fault raised in OnResumeAsync is not retried, because the resume cannot be delivered again. A SecureString read after a wait is a warning (VWF1326): secrets do not survive a save. Read secrets again after the wait instead of keeping them. Test both halves with ActivityTestHost.RunAsync and ResumeAsync. See Test activities. Activities with children# A ContainerActivity declares one or more slots with [ActivitySlot]. The workflow author drops one activity (or a Sequence) into each slot, and your activity decides when the engine runs it. RepeatActivity.csC#Copyusing Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Metadata; namespace Contoso.Invoices.Activities; [ActivityType("contoso.invoices.repeat")] [ActivityInfo(DisplayName = "Repeat", Category = "Invoices", Description = "Runs its body a number of times.", IconKey = "Repeat")] [ActivityCharacteristics(Idempotent = false, RetrySafe = false, Risk = ActivityRisk.None)] [ActivitySlot("Body", DisplayName = "Do")] public sealed class RepeatActivity : ContainerActivity { [RequiredArgument] [ArgumentInfo(DisplayName = "Count", Order = 0)] public InArgument<int> Count { get; set; } = new(); // Outputs set in one step are visible in the next: this is the container's state. [ArgumentInfo(DisplayName = "Completed", Order = 10)] public OutArgument<int> Completed { get; set; } = new(); protected override void Execute(CodeActivityContext context, string? completedSlot) { ArgumentNullException.ThrowIfNull(context); int done = completedSlot is null ? 0 : context.GetValue(Completed) + 1; context.SetValue(Completed, done); if (done < context.GetValue(Count)) { context.ScheduleSlot("Body"); } } } Execute runs first with completedSlot set to null. To run a slot's child next, call context.ScheduleSlot(name) and return. When the child completes, Execute runs again, on a new object, with the name of the slot that completed. Return without scheduling to complete. Keep state between steps in output arguments: context.GetValue(outArgument) reads back what an earlier step set. A fault in a child propagates through the container, as through a Sequence. Authors handle it with a Try Catch inside the slot. A container runs in the workflow's own process. It cannot declare RequiresIsolation (VWF1330), and a retry policy on it is an error (VWF1327). Studio draws a container card with one drop area per slot from the [ActivitySlot] attributes. No design code is needed. Composite activities# A CompositeActivity is expanded by the workflow compiler into other workflow elements: you override Build(CompositeActivityBuilder builder) and return a workflow node tree. The composite object exists only at compile time and never runs, so the result saves, resumes and faults exactly like hand-built workflow logic. Use builder.Id(localId) for every element id, builder.Argument(name) to refer to the composite's arguments and builder.Variable(name, type) for variables. Recursion is refused and nesting is limited to 8 levels. If what you want is reusable workflow logic, a workflow library built in Studio is usually simpler. Validate bindings at compile time# Declarative rules ([RequiredArgument], [RequiredWhen], [Range], [RegularExpression], [MutuallyExclusive], see Design-time experience) cover most checks. For anything else, write an IActivityValidator and name it with [ActivityValidator]. The workflow compiler runs it on every use of the activity, before any run starts. CheckInvoiceAmountActivity.csC#Copyusing Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Metadata; namespace Contoso.Invoices.Activities; [ActivityType("contoso.invoices.checkAmount")] [ActivityInfo(DisplayName = "Check Invoice Amount", Category = "Invoices", IconKey = "ValidateInvoice")] [ActivityCharacteristics(Idempotent = true, RetrySafe = true, Risk = ActivityRisk.None)] [ActivityValidator(typeof(CheckInvoiceAmountValidator))] public sealed class CheckInvoiceAmountActivity : CodeActivity { [ArgumentInfo(DisplayName = "Minimum", Order = 0)] public InArgument<decimal> Minimum { get; set; } = new(); [ArgumentInfo(DisplayName = "Maximum", Order = 1)] public InArgument<decimal> Maximum { get; set; } = new(); [RequiredArgument] [ArgumentInfo(DisplayName = "Amount", Order = 2)] public InArgument<decimal> Amount { get; set; } = new(); [ArgumentInfo(DisplayName = "In range", Order = 10)] public OutArgument<bool> InRange { get; set; } = new(); protected override void Execute(CodeActivityContext context) { ArgumentNullException.ThrowIfNull(context); decimal amount = context.GetValue(Amount); context.SetValue(InRange, amount >= context.GetValue(Minimum) && amount <= context.GetValue(Maximum)); } } public sealed class CheckInvoiceAmountValidator : IActivityValidator { public void Validate(ActivityValidationContext context) { ArgumentNullException.ThrowIfNull(context); // Only literal bindings are visible here; expressions are never evaluated at compile time. if (context.LiteralValues.TryGetValue("Minimum", out object? min) && context.LiteralValues.TryGetValue("Maximum", out object? max) && min is decimal low && max is decimal high && low > high) { context.ReportError("Maximum", "Maximum is lower than Minimum.", "Swap the two values."); } } } ActivityValidationContext gives you BoundArguments (which arguments have a binding), LiteralValues (the values of literal bindings), Ancestors and IsInsideScope(kind) (where the activity sits in the workflow). Each ReportError becomes an error (VWF1307). A validator never sees expression values, and it must not call external systems. Contribute a run service# If several of your activities share a service, such as a client cache, register it once per run with a service contributor. Activities then resolve it with context.GetService<T>(). InvoiceServices.csC#Copyusing Velophex.Workflow.Activities.Services; [assembly: WorkflowServiceContributor(typeof(Contoso.Invoices.Activities.InvoiceServiceContributor))] namespace Contoso.Invoices.Activities; public interface IInvoiceCache { bool TryGet(string number, out string? status); } public sealed class InvoiceCache : IInvoiceCache { public bool TryGet(string number, out string? status) { status = null; return false; } } public sealed class InvoiceServiceContributor : IWorkflowServiceContributor { public void Contribute(WorkflowServiceContext context, IWorkflowServiceRegistry services) => services.TryAdd<IInvoiceCache>(new InvoiceCache()); } Before a run starts, the executor creates each declared contributor once and calls Contribute with the project root (context.ProjectRoot). TryAdd never replaces a service that is already registered: the host's own services always win. A contributor that throws is logged as a warning and the run continues. The activity that needs the service should fail with a clear message when GetService<T>() returns null. A contributed service that is IDisposable or IAsyncDisposable is disposed when the run ends. Next steps# Design-time experience Test activities

Wait for an external event

Activities with children

Composite activities

Validate bindings at compile time

Contribute a run service

Next steps