Write activities

Write activities against Velophex.Workflow.Sdk - base classes, type ids, arguments, the activity context, async work and cancellation, errors, logging, secrets and execution characteristics.

An activity is a public class in your runtime project that derives from one of the SDK's base classes and carries an [ActivityType] attribute. Everything here uses Velophex.Workflow.Sdk only. Anatomy of an activity# ValidateInvoiceNumberActivity.csC#Copyusing System.Text.RegularExpressions; using Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Design; using Velophex.Workflow.Activities.Metadata; namespace Contoso.Invoices.Activities; [ActivityType("contoso.invoices.validateInvoiceNumber")] [ActivityInfo( DisplayName = "Validate Invoice Number", Category = "Invoices", Description = "Checks that an invoice number has the INV-000000 format.", IconKey = "ValidateInvoice", Keywords = "invoice,check,format")] [ActivityCharacteristics(Idempotent = true, RetrySafe = true, Deterministic = true, Risk = ActivityRisk.None)] public sealed class ValidateInvoiceNumberActivity : CodeActivity { [RequiredArgument] [ArgumentInfo( DisplayName = "Invoice number", Description = "The number to check.", Order = 0, Placement = PropertyPlacement.Card, Placeholder = "\"INV-000123\"")] public InArgument<string> InvoiceNumber { get; set; } = new(); [ArgumentInfo(DisplayName = "Is valid", Description = "True when the number is well formed.", Order = 1)] public OutArgument<bool> IsValid { get; set; } = new(); protected override void Execute(CodeActivityContext context) { ArgumentNullException.ThrowIfNull(context); string value = context.GetValue(InvoiceNumber) ?? string.Empty; context.SetValue(IsValid, Regex.IsMatch(value, "^INV-[0-9]{6}$")); } } [ActivityType] is the permanent type id. It is required. [ActivityInfo] is the toolbox entry: name, category, description, icon key and search keywords. Add icons/ValidateInvoice.svg beside the runtime project's source for the icon key. See Design-time experience. [ActivityCharacteristics] tells the engine what a retry or a recovery may do. See Characteristics. Each public InArgument<T>, OutArgument<T> or InOutArgument<T> property is an argument. Initialize it with new(). Choose a base class# All base classes are in the Velophex.Workflow.Activities namespace. Base class Override Use it for CodeActivity void Execute(CodeActivityContext context) Quick, synchronous work: parsing, calculating, checking CodeActivity<TResult> TResult Execute(CodeActivityContext context) The same, with one output: the base class adds an OutArgument<TResult> Result and sets it from your return value AsyncCodeActivity ValueTask ExecuteAsync(AsyncCodeActivityContext context, CancellationToken cancellationToken) I/O: HTTP calls, files, databases, anything you await AsyncCodeActivity<TResult> ValueTask<TResult> ExecuteAsync(...) Async work with a Result output DurableActivity ExecuteAsync(DurableActivityContext, ...) and optionally OnResumeAsync(...) Waiting for an external event for minutes or days without holding a Robot. See Advanced activities. ContainerActivity void Execute(CodeActivityContext context, string? completedSlot) An activity that holds child activities the engine runs. See Advanced activities. CompositeActivity WorkflowNode Build(CompositeActivityBuilder builder) An activity the compiler expands into other workflow elements. It never runs as code. An activity class must derive from one of these, directly or through an abstract base class in the same assembly. A base class in another assembly fails the pack (VXMAN001). The engine creates a new instance of your class for every execution, with a public parameterless constructor. Do not keep run state in fields: read inputs from the context and write outputs to it. A static field is fine for something every execution can share safely, such as an HttpClient. Type ids# The type id is written into every workflow that uses the activity. It is how a saved workflow finds your activity again. Use the form <prefix>.<camelCaseName>, with your package's prefix: contoso.invoices.validateInvoiceNumber. The prefix velophex. is reserved for VeloPhex's own packages. Type ids must be unique within your package and must not reuse another package's prefix. Two installed packages that declare the same type id are refused (VXPKG041). Never change a type id after you ship it. There is no migration: a renamed type id breaks every workflow that used the old one. To retire an activity, set [ActivityInfo(Hidden = true)] so it leaves the toolbox but existing workflows keep running. The same applies to argument names: the property name is what the workflow file stores. Renaming a property, or changing its type incompatibly, is a breaking change. To change an activity's contract, ship a new activity with a new type id. Arguments# Type Direction Read with Write with InArgument<T> Into the activity context.GetValue(arg) OutArgument<T> Out of the activity context.GetValue(arg) (what you set earlier) context.SetValue(arg, value) InOutArgument<T> Both context.GetValue(arg) context.SetValue(arg, value) An input the workflow does not bind reads as default(T): null, 0, false. Mark arguments the activity cannot work without with [RequiredArgument]; Studio and the workflow compiler then report a missing binding as an error (VWF1302). Outputs are written to their targets when the activity completes. If the activity throws, its outputs are not written. T can be any .NET type. Prefer simple, serializable types (strings, numbers, records, DataTable) for values that a workflow keeps in variables: a workflow that waits is saved, and its variables are saved with it. Timeout and ContinueOnError are reserved names, compared ignoring case. The engine provides both on every activity, in Studio's Common group. An activity that declares an argument or public property with either name fails to pack (VXPKG051). Give a domain timeout its own name, such as TimeoutSeconds. Studio presentation of arguments (labels, groups, card placement, dropdowns, conditions, validation) is covered in Design-time experience. The activity context# The context passed to Execute or ExecuteAsync is your activity's view of the run. CodeActivityContext, AsyncCodeActivityContext and DurableActivityContext all derive from ActivityContext (namespace Velophex.Workflow.Activities.Context). Member What it gives you GetValue(argument), SetValue(argument, value) Read inputs, write outputs CancellationToken Canceled when the run is canceled. AsyncCodeActivity also receives it as a parameter. IsStopRequested true after someone asked the run to stop (an operator's Stop, a debugger stop request). Nothing is canceled; a long activity can check it between units of work and finish early. Logger Writes to the run's log. See Write to the log. NodeId, WorkflowInstanceId, CorrelationId Which workflow element and which run this is, for tracing AttemptNumber 1 on the first attempt, higher on a retry IdempotencyKey A key that is the same for every attempt of this execution, including after a crash. Pass it to external systems that deduplicate requests. GetService<T>() A service the host or a package provides, or null. Engine internals are not available. GetSecretAsync(handle), ProtectSecret(value) Read or create a secret handle (needs the Credentials capability). See Secrets. AddAnnotation(key, value) Records a key/value note in the run's journal. Never put secrets in it. RecordVerification(...) Records a pass/fail check that a test run reports Scopes, Resources, GetScope<T>(kind) Live resources shared through a scope. See Scopes. Async work and cancellation# Derive from AsyncCodeActivity for anything that waits on I/O, and pass the cancellation token to every call: GetInvoiceStatusActivity.csC#Copyusing System.ComponentModel.DataAnnotations; using System.Net; using System.Net.Http.Headers; using System.Security; using Velophex.Workflow; using Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Design; using Velophex.Workflow.Activities.Metadata; namespace Contoso.Invoices.Activities; [ActivityType("contoso.invoices.getInvoiceStatus")] [ActivityInfo( DisplayName = "Get Invoice Status", Category = "Invoices", Description = "Reads the status of an invoice from the Contoso billing API.", IconKey = "InvoiceStatus")] [ActivityCapabilities(ActivityCapabilities.Network | ActivityCapabilities.Credentials)] [ActivityCharacteristics(Idempotent = true, RetrySafe = true, Risk = ActivityRisk.None)] public sealed class GetInvoiceStatusActivity : AsyncCodeActivity { // One client per process, with the default handler, so the platform's proxy settings apply. private static readonly HttpClient Http = new(); [RequiredArgument] [ArgumentInfo(DisplayName = "Base URL", Order = 0, Placeholder = "\"https://billing.contoso.com/\"")] public InArgument<string> BaseUrl { get; set; } = new(); [RequiredArgument] [ArgumentInfo(DisplayName = "Invoice number", Order = 1, Placement = PropertyPlacement.Card)] public InArgument<string> InvoiceNumber { get; set; } = new(); [RequiredArgument] [ArgumentInfo( DisplayName = "API key", Description = "Bind it from Get Credential or Get Secret.", Order = 2, Sensitive = true, ValueModes = PropertyValueModes.Expression | PropertyValueModes.Variable, EditorHint = EditorKeys.Password)] public InArgument<SecureString> ApiKey { get; set; } = new(); [Range(1, 300)] [ArgumentInfo(DisplayName = "Timeout (seconds)", Order = 3, DefaultValue = 30, IsAdvanced = true)] public InArgument<int> TimeoutSeconds { get; set; } = new(); [ArgumentInfo(DisplayName = "Status", Description = "The invoice status, for example Paid.", Order = 10)] public OutArgument<string> Status { get; set; } = new(); protected override async ValueTask ExecuteAsync( AsyncCodeActivityContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); string number = context.GetValue(InvoiceNumber); int seconds = context.GetValue(TimeoutSeconds); string apiKey = new NetworkCredential(string.Empty, context.GetValue(ApiKey)).Password; // The run's token cancels the call when the job is canceled; the linked source adds this call's own timeout. using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(seconds > 0 ? seconds : 30)); using HttpRequestMessage request = new( HttpMethod.Get, new Uri(new Uri(context.GetValue(BaseUrl)), $"invoices/{Uri.EscapeDataString(number)}/status")); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); context.Logger.Log($"Reading the status of invoice {number}."); using HttpResponseMessage response = await Http.SendAsync(request, timeout.Token).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { // An expected business outcome, not a defect: it is never retried. throw new BusinessRuleException($"Invoice {number} does not exist.", "INVOICE_NOT_FOUND"); } response.EnsureSuccessStatusCode(); string status = (await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false)).Trim(); context.SetValue(Status, status); } } Never block on async code (.Result, .Wait()). Use AsyncCodeActivity instead. Let OperationCanceledException from the run's token propagate. A canceled run is not a fault. The workflow author can set Timeout on any activity. When it expires the engine cancels the token and reports a timeout, so honor the token everywhere. For long loops over many items, check context.IsStopRequested between items and finish cleanly when it is true. Files, HTTP and proxies# The SDK has no file or HTTP wrappers. Use System.IO and HttpClient directly, and declare what you use with [ActivityCapabilities] (FileSystem, Network; see Reference). The platform configures an outbound proxy through .NET's default proxy (the system proxy, or HTTPS_PROXY, HTTP_PROXY and NO_PROXY). Create clients with the default handler, new HttpClient() or a SocketsHttpHandler that leaves UseProxy on and Proxy unset, and never turn the proxy off. Secrets and sensitive values# Take passwords, tokens and API keys as InArgument<SecureString> marked sensitive, as the API key in the example above does: Sensitive = true redacts the value from logs, the run journal and inspection, and gives it a password editor. ValueModes = PropertyValueModes.Expression | PropertyValueModes.Variable stops anyone from typing the secret into the workflow file as a literal (VWF1323). Workflow authors bind it from Get Credential or Get Secret. Convert it to text only at the moment you use it: new NetworkCredential(string.Empty, secureString).Password. Declare ActivityCapabilities.Credentials on activities that handle credentials. A workflow can also pass a secret as a SecretHandle (namespace Velophex.Workflow.Security). await context.GetSecretAsync(handle, cancellationToken) returns a SecretValue that you dispose as soon as you have used it; its Use(...) method passes the characters to your code without creating a string. An activity that collects a secret can hand it to the run with context.ProtectSecret(value) and output the returned handle instead of the text. Both need the Credentials capability. Never read secrets from environment variables or files, and never log them. Report failures# Throw an exception. The engine records it as the activity's fault, applies the workflow author's retry policy, Continue On Error and Try Catch, and logs it. Do not catch exceptions just to swallow them: an activity that hides a failure leaves the workflow running on wrong data. Throw When Effect Any exception, such as InvalidOperationException or HttpRequestException Something went wrong that might go right next time An Activity fault. The workflow's retry policy may retry it if the activity is retry-eligible. BusinessRuleException (namespace Velophex.Workflow) An expected business outcome, such as a rejected invoice A Business fault with your fault code (default BUSINESS_RULE_VIOLATED). Never retried. It is the same contract as the Python SDK's BusinessError. WorkflowFaultException (namespace Velophex.Workflow.Execution) You want to set the fault category and a stable code yourself, for example WorkflowFaultCategory.Validation The category you choose. Set IsRetryable to say whether running the job again could help. C#Copythrow new BusinessRuleException("Invoice total is negative.", "INVOICE_REJECTED"); throw new WorkflowFaultException(WorkflowFaultCategory.Validation, "CONTOSO-INV-001", "The invoice number is empty.") { IsRetryable = false }; Write messages for the person reading the job log: say what failed and what to do. Never put secrets in exception messages. Write to the log# C#Copycontext.Logger.Log("Read 42 invoices."); context.Logger.Log("The billing API is slow to answer.", "Warning"); The level is a string: Trace (or Verbose), Debug, Information (the default), Warning, Error, or Fatal (or Critical). Anything else is logged as Information. Lines appear in Studio's Output panel during a local run and in the job log on the Orchestrator, with any log fields the workflow added (Add Log Fields). Do not log secrets or personal data you would not want in a job log. Characteristics: retries, recovery and risk# [ActivityCharacteristics] declares how your activity behaves. The engine uses it to decide what a retry or a crash recovery may do, and the manifest carries Idempotent, RetrySafe and Risk so Studio and policies can see them without loading your code. Property Default Declare true when Idempotent false Running it twice with the same inputs has no additional effect (a read, a set-to-value) RetrySafe false It may be retried automatically after a failure Deterministic false The same inputs always give the same outputs Persistable true Set it to false when what the activity holds is a live resource that cannot be saved (see Isolation and persistence) RequiresIsolation false It must run in a separate host process, one activity per process Privileged false It needs elevated host trust SessionAffinity false It keeps interactive session state, such as window handles Risk undeclared Always set it: ActivityRisk.None, Low, Medium or High Retries. The workflow author's retry policy, and the Retry action of the global exception handler, only retry an activity that is idempotent, retry-safe or replay-safe. If a timed-out attempt may still have had an effect, only an idempotent activity is retried. Every attempt of one execution shares the same context.IdempotencyKey, so an external system can recognise a repeat. Crash recovery. If the Robot stops while your activity is running, the engine decides on restart with [ActivityRecovery(...)] (namespace Velophex.Workflow.Activities.Metadata, enum ActivityRecoveryBehavior in Velophex.Workflow): Behavior On restart ReplaySafe Runs the activity again. Idempotent = true counts as replay-safe. ProbeThenReplay Calls ProbeAsync(context, attemptId) on your activity, which implements IActivityRecoveryProbe (namespace Velophex.Workflow.Activities.Recovery), to ask whether the interrupted attempt took effect: Applied completes the step without running it, NotApplied runs it again, Unknown waits for a decision. The probe itself must be safe to run more than once. NonReplayable (the default) Does not run it again. The job is suspended for someone to decide. Risk. Declare the highest risk any configuration of the activity can reach. An activity that declares no risk is treated as unknown, not as harmless. ActivityRisk Meaning None Reads or waits; changes nothing Low Sets a value or state that a repeat sets again to the same result Medium An effect the target system decides; a repeat can have a second effect High Runs code, deletes, sends, closes or opens; a repeat is a second, unrecoverable effect. An activity that runs code supplied by the workflow author is always High. Isolation and persistence# RequiresIsolation = true runs each execution of the activity in an isolated host process. An activity that holds children cannot declare it (VWF1330); declare it on the activities inside instead. A workflow is saved at persistence points: Persist, Wait For Event, Delay and durable activities. Persistable = false declares that the activity holds something that cannot be saved; a persistence point inside it is an error (VWF1328). A workflow variable or argument of a live-resource type (a stream, a database connection, transaction or reader, a process, a thread, a socket, a task or an HttpClient) that can hold a value at a persistence point is an error (VWF1329). Do not output such types; expose them through a scope instead. Next steps# Scopes: share a connection, session or document between activities. Advanced activities: long waits, child activities, compile-time validators and run services. Design-time experience

Anatomy of an activity

Choose a base class

Type ids

Arguments

The activity context

Async work and cancellation

Files, HTTP and proxies

Secrets and sensitive values

Report failures

Write to the log

Characteristics: retries, recovery and risk

Isolation and persistence

Next steps