Scopes and live resources
Share a live connection, session or document between activities with a Use scope - the open step, the close step and child activities.
Some activities need a live thing: a database connection, an authenticated client, a terminal session, an open document. A workflow variable cannot hold one safely, because a waiting workflow is saved and a live handle cannot be saved, and nobody would dispose it. Expose it through a scope instead, the same shape as Use Application/Browser and Use Excel File. A scope has three kinds of activity: Part Example in the template What it does Open step Use Greeter Opens the resource, registers it with the run and pushes a scope frame. Carries [ScopeProvider]. Close step End Use Greeter Releases the resource and pops the frame. Studio places it in the scope's Finally, so it runs on success, fault and cancellation. Authors never need to drop it themselves; [ActivityInfo(Hidden = true)] keeps it out of the toolbox. Children Greet Use the innermost enclosing scope of their kind, or the session their Session argument names. Carry [RequiresScope]. When an author drops the open step from the toolbox, Studio builds the whole container from the manifest: a sequence with a Try that holds the open step and the body, and a Finally that holds the close step. A child placed outside any matching scope is a design-time error (VWF1310) and faults if it runs. Write a scope# The template's UseGreeterActivity.cs is a complete, working scope. Replace Greeter with your client or session. UseGreeterActivity.csC#Copyusing Velophex.Workflow.Activities; using Velophex.Workflow.Activities.Arguments; using Velophex.Workflow.Activities.Context; using Velophex.Workflow.Activities.Design; using Velophex.Workflow.Activities.Metadata; using Velophex.Workflow.Activities.Scopes; namespace Contoso.Invoices.Activities; public static class GreeterScope { // The scope kind: the open step provides it, children require it. Permanent once shipped. public const string Kind = "contoso.invoices.greeter"; public const string UseGreeter = "contoso.invoices.useGreeter"; public const string EndScope = "contoso.invoices.endScope"; public const string Greet = "contoso.invoices.greet"; } // The live thing the scope holds. A real package's is a client or a session. public sealed class Greeter(string salutation) : IDisposable { public string Salutation { get; } = salutation; public int Count { get; set; } public void Dispose() { } } [ActivityType(GreeterScope.UseGreeter)] [ActivityInfo( DisplayName = "Use Greeter", Category = "Invoices", Description = "Opens a greeter for the activities placed inside it.", IconKey = "Greeter")] [ActivityCharacteristics(Idempotent = false, RetrySafe = true, Risk = ActivityRisk.None)] [ScopeProvider(Kind = GreeterScope.Kind, Display = "Use Greeter", CloseActivity = GreeterScope.EndScope)] public sealed class UseGreeterActivity : CodeActivity { [ArgumentInfo(DisplayName = "Salutation", Order = 0, Placement = PropertyPlacement.Card, Placeholder = "\"Hello\"", DefaultValue = "Hello")] public InArgument<string> Salutation { get; set; } = new(); // The session id, for a child outside the scope's body or in an invoked workflow. [ArgumentInfo(DisplayName = "Session id", Order = 50)] public OutArgument<string> SessionId { get; set; } = new(); protected override void Execute(CodeActivityContext context) { ArgumentNullException.ThrowIfNull(context); string salutation = context.GetValue(Salutation); var greeter = new Greeter(string.IsNullOrWhiteSpace(salutation) ? "Hello" : salutation); string sessionId = $"greeter-{Guid.NewGuid():N}"; // The frame carries a serializable reference; the live object goes into Resources. ScopeFrame frame = context.Scopes.Push(GreeterScope.Kind, sessionId); try { context.Resources.Register(sessionId, greeter); } catch { context.Scopes.Pop(frame); greeter.Dispose(); throw; } context.SetValue(SessionId, sessionId); } } [ActivityType(GreeterScope.EndScope)] [ActivityInfo( DisplayName = "End Use Greeter", Category = "Invoices", Description = "Ends a Use Greeter scope; placed by the scope.", IconKey = "Greeter")] [ActivityCharacteristics(Idempotent = true, RetrySafe = true, Risk = ActivityRisk.None)] public sealed class EndGreeterScopeActivity : AsyncCodeActivity { // The node id of the open step this ends; the scope sets it. [RequiredArgument] [ArgumentInfo(DisplayName = "Scope", Order = 0)] public InArgument<string> ScopeId { get; set; } = new(); protected override async ValueTask ExecuteAsync(AsyncCodeActivityContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); // Never throw for a scope that did not open. if (!context.Scopes.TryGetByNode(context.GetValue(ScopeId) ?? string.Empty, out ScopeFrame? frame)) { return; } // Not the run's token: this runs in a Finally, often because the run is being canceled. await context.Resources.ReleaseAsync(frame.Reference, CancellationToken.None).ConfigureAwait(false); context.Scopes.Pop(frame); } } [ActivityType(GreeterScope.Greet)] [ActivityInfo( DisplayName = "Greet", Category = "Invoices", Description = "Greets a name with the enclosing greeter.", IconKey = "SayHello")] [ActivityCharacteristics(Idempotent = false, RetrySafe = true, Risk = ActivityRisk.None)] [RequiresScope(Kind = GreeterScope.Kind, Argument = nameof(Session))] public sealed class GreetActivity : CodeActivity { [RequiredArgument] [ArgumentInfo(DisplayName = "Name", Order = 0, Placement = PropertyPlacement.Card, Placeholder = "\"World\"")] public InArgument<string> Name { get; set; } = new(); [ArgumentInfo(DisplayName = "Greeting", Order = 1)] public OutArgument<string> Greeting { get; set; } = new(); // Empty uses the innermost enclosing Use Greeter. [ArgumentInfo(DisplayName = "Session", Description = "A Use Greeter session id. Empty uses the enclosing Use Greeter.", Order = 90)] public InArgument<string> Session { get; set; } = new(); protected override void Execute(CodeActivityContext context) { ArgumentNullException.ThrowIfNull(context); // The bound Session argument if set, else the innermost frame of this kind. string sessionId = context.GetScope<string>(GreeterScope.Kind); if (!context.Resources.TryResolve(sessionId, out Greeter? greeter)) { throw new InvalidOperationException($"Greet: the greeter '{sessionId}' is not open."); } greeter.Count++; context.SetValue(Greeting, $"{greeter.Salutation}, {context.GetValue(Name)}! (#{greeter.Count})"); } } How it works# Frames (context.Scopes) hold a serializable reference, here a session id string. Frames are saved with the workflow, so a resumed run still sees them. Pops must nest: only the innermost frame can be popped. Resources (context.Resources) hold the live object under that reference. A resource must implement IDisposable or IAsyncDisposable. Resources never survive the process: a run resumed on another machine must reopen from the reference. Children call context.GetScope<T>(kind). It returns the bound override argument (named in [RequiresScope(Argument = ...)], by convention Session) if there is one, otherwise the innermost open frame of that kind, otherwise throws ScopeRequiredException with a message that tells the author which scope to use. TryGetScope<T> does the same without throwing. Invoked workflows see their parent's frames, so a child activity inside an invoked workflow uses the caller's session. Leaks are cleaned up. Anything a run leaves open is disposed by the engine when the run ends and logged as a leaked scope. Still, always release in the close step. Scope options# Option Where Effect [ScopeProvider(CompleteActivity = "<type id>")] Open step A complete step, such as Save or Commit, that Studio places after the body and that runs only when the body succeeded [ScopeProvider(Shareable = true)] Open step Branches that run at the same time (Parallel, Pick, Parallel For Each) may use one scope opened above them. Without it, that is an error (VWF1316). Opt in only when the handle is safe to use concurrently, such as an immutable HttpClient; a desktop, a document or a database connection is not. [RequiresScope(Fallback = ScopeFallback.MostRecent)] Child The child has its own fallback (for example its own connection string) when it is outside a scope. The compiler then reports nothing; resolve the fallback yourself in Execute and log a warning. Naming conventions: the scope is Use Thing with type id <prefix>.use<Thing>, the close step is <prefix>.endScope with a ScopeId input, and the override argument is Session. Test a scope# ActivityTestHost has its own Scopes and Resources, shared by every activity it runs, so you can run the open step, then a child, then the close step. See Test activities. Next steps# Advanced activities Design-time experience
Write a scope
How it works
Scope options
Test a scope
Next steps