maac_workflow 0.1.1 copy "maac_workflow: ^0.1.1" to clipboard
maac_workflow: ^0.1.1 copied to clipboard

maac_workflow is a resilient, stateful, and declarative asynchronous workflow and step pipeline engine for Dart & Flutter.

maac_workflow #

pub package

A highly resilient, stateful, and declarative asynchronous workflow and step pipeline orchestration engine for Dart & Flutter.


🌟 Overview #

In modern Flutter applications, business processes such as multi-step signup flows, complex checkout processes, or sequential API setups are often written directly inside ViewModels or UI widgets. This inevitably leads to:

  • Callback Hell / Async Sprawl: Hard to maintain, reason about, or debug.
  • Complex Rollback / Compensation: Extemely tedious to clean up intermediate states if a later step fails.
  • Brittle Retry Policies: Manual retry logic cluttering business rules.
  • Poor Analytics & Logging: Impossible to track process status in a unified manner.

maac_workflow introduces a clean, domain-driven Step Pipeline Architecture to solve these issues. It isolates sequential, stateful operations into independent, reusable, and compensable Steps, coordinated by a powerful Workflow Engine.


πŸš€ Key Features #

Core

  • πŸ”’ Scoped Immutability: FlowContext, the required base for every workflow's shared state, is a key-value store that makes data written by one step strictly read-only to every step that runs after it β€” enforced by the engine, not by convention.
  • πŸ”„ Transactional Rollbacks (Compensation): Automatically triggers rollback operations in reverse order (LIFO) for previously succeeded steps if a later step fails.
  • πŸ›‘οΈ Type-Safe sealed Outcomes: Uses Dart 3 sealed classes (WorkflowSuccess, WorkflowFailure, WorkflowCancelled) to enforce compile-time exhaustive checking in UI/ViewModel.
  • πŸ›‘ Integrated Cancellation: Simple cancellation tokens to abort workflows mid-flight when a user navigates away or manually cancels. WorkflowStep.onDeactivateOrCancel gives the actively-running step an immediate cleanup hook.
  • πŸ“Š Auditing & Telemetry: WorkflowListener lifecycle hooks β€” including every FlowContext write β€” to seamlessly bind global loading indicators, telemetry, or debug logging.

Step Definitions

  • πŸͺΆ Flexible Step Definition: Define a step by passing its handler functions straight into WorkflowStep.action(...) β€” no subclass required for simple steps.
  • πŸ”‚ Sustained Steps: WorkflowStep.sustained(...) models work with no natural completion of its own β€” a live stream subscription, an open connection, a polling loop β€” that runs until the step is cancelled or deactivated, without hand-rolling a Completer/CancellationToken dance per step.
  • ⏸️ Interactive Steps: InteractiveStep pauses a workflow to wait for an external signal β€” typically UI input β€” without any step author hand-rolling a Completer. Resume it from outside via WorkflowRunner.submit/.fail.

Composites & Decorators β€” all interoperable, since each is itself a WorkflowStep

  • ConditionalStep to branch flows cleanly using dynamic predicates.
  • WorkflowStepGroup to embed a whole sub-pipeline as a single step, so large flows compose out of smaller, named, independently testable ones.
  • ParallelStepGroup to run independent asynchronous steps concurrently as a single step β€” the concurrent counterpart to WorkflowStepGroup, with an optional listener to observe each sub-step's own lifecycle individually.
  • RetryStepDecorator to automatically retry brittle steps with custom delay policies (e.g., Exponential Backoff).
  • TimeoutStepDecorator to fail a step that runs longer than expected, without cancelling the whole workflow.

Progress

  • πŸ“ Live Step Progress: WorkflowRunner.progress exposes a ValueListenable<WorkflowProgress> with the currently executing step id and the last known status of every step β€” drive a stepper/progress bar UI without hand-rolling counters in WorkflowListener.

Concurrency

  • πŸ”€ Concurrency Strategies: ManagedWorkflowRunner governs what happens when a workflow is triggered again while already running β€” ignore, cancelExisting, or enqueue β€” plus ParallelWorkflowRunner for fully independent concurrent runs and SharedWorkflowRunner for merging multiple callers into one shared session.

πŸ“¦ Installation #

Add maac_workflow to your pubspec.yaml:

dependencies:
  maac_workflow:
    path: packages/maac_workflow # For local melos workspaces

πŸ› οΈ Quick Start #

1. Define your Workflow Context #

Every WorkflowRunner<TContext> requires TContext to extend FlowContext β€” the base every workflow's shared state builds on (see "FlowContext β€” Scoped Immutability" further down for what it buys you). The simplest way to use it is exactly like a regular mutable data class, just extending FlowContext instead of nothing:

class SignupContext extends FlowContext {
  final String email;
  final String password;
  final String? avatarPath;
  final String? referralCode;

  String? userId;
  String? uploadedAvatarUrl;

  SignupContext({
    required this.email,
    required this.password,
    this.avatarPath,
    this.referralCode,
  });

  bool get hasAvatar => avatarPath != null;
  bool get hasReferralCode => referralCode != null && referralCode!.isNotEmpty;
}

2. Implement Your Steps #

Inherit from WorkflowStep<T> to implement individual operations. Optionally implement rollback to support compensation if subsequent steps fail.

class CreateAccountStep extends WorkflowStep<SignupContext> {
  @override
  String get id => 'create_account';

  @override
  Future<StepResult> execute(SignupContext context, CancellationToken token) async {
    try {
      // Simulate API call to create the account
      final userId = await authApi.createAccount(context.email, context.password);
      context.userId = userId;
      return const StepSuccess();
    } catch (e, stack) {
      return StepFailure(e, stack);
    }
  }

  @override
  Future<void> rollback(SignupContext context) async {
    // If the account was created, but a later step fails, delete it so the user
    // can retry signup with the same email instead of hitting a "duplicate email" error.
    if (context.userId != null) {
      await authApi.deleteAccount(context.userId!);
      context.userId = null;
    }
  }
}

For simple steps, skip the class entirely and use WorkflowStep.action(...) β€” see "WorkflowStep.action β€” Functional Steps" further down.

3. Orchestrate and Run the Workflow #

Run your pipeline declaratively. Wrap brittle steps with retry decorators, and conditionally execute steps.

final signupWorkflow = WorkflowRunner<SignupContext>(
  steps: [
    // Automatically retry account creation up to 3 times with exponential backoff on network issues
    RetryStepDecorator(
      step: CreateAccountStep(),
      maxAttempts: 3,
      initialDelay: const Duration(seconds: 1),
    ),
    // Branching: only execute avatar upload if the user picked one
    ConditionalStep(
      id: 'conditional_upload_avatar',
      condition: (ctx) => ctx.hasAvatar,
      step: UploadAvatarStep(),
    ),
    ConditionalStep(
      id: 'conditional_apply_referral',
      condition: (ctx) => ctx.hasReferralCode,
      step: ApplyReferralCodeStep(),
    ),
    SendWelcomeEmailStep(),
  ],
);

// Execute the workflow
final result = await signupWorkflow.run(
  SignupContext(email: 'jane@doe.com', password: 's3cret'),
);

// Safely pattern match results using Dart 3 sealed class capabilities
switch (result) {
  case WorkflowSuccess():
    print('Signup Completed Successfully!');
  case WorkflowFailure(:final failedStepId, :final error):
    print('Failed at step [$failedStepId] with error: $error');
  case WorkflowCancelled():
    print('Signup was cancelled.');
}

🧱 Core #

FlowContext β€” Scoped Immutability #

Every TContext extends FlowContext, which β€” beyond being a normal mutable Dart object, as shown above β€” is also a key-value store that can enforce a stronger rule: once a step has written a key, every step that runs after it gets read-only access. This models what "shared context across a pipeline" should mean in a strict pipeline β€” later steps see history, they don't rewrite it.

class SignupContext extends FlowContext {}

// Inside a step's execute()/onSubmit():
context.write('userId', newUserId);       // first writer claims the key
context.read<String>('userId');           // any step can always read it

// A later step trying to overwrite it:
context.write('userId', otherId);          // throws StateError

A step overwriting its own key is always allowed β€” this is what lets a step's own rollback() reset the same key it wrote during execute(). Writes made with no step active (before a run starts, or between runs β€” e.g. seeding input from whatever code configures the run) are always unrestricted and reset that key's ownership, since this rule is about steps not stepping on each other, not about the code that configures a run:

class SignupController {
  final _context = SignupContext();

  void startFlow() {
    _context.write('forceFailure', false); // fine β€” no step is active yet
    _signupWorkflow.run(_context);
  }
}

You don't have to use the store at all β€” plain mutable fields (as in the Quick Start example above) work exactly as before and simply don't participate in this tracking. Reach for the store when you specifically want the engine to enforce read-only history for a piece of shared state.

WorkflowStep & WorkflowRunner β€” Sequential Run & Rollback #

The core contract, already exercised end to end in "Quick Start" above: a WorkflowRunner<TContext> runs steps in order, and if one fails, every step that already succeeded is rollbacked automatically, in reverse (LIFO) order:

final result = await WorkflowRunner<SignupContext>(steps: [stepA, stepB]).run(context);
switch (result) {
  case WorkflowSuccess(): ...
  case WorkflowFailure(:final failedStepId, :final error): ...
  case WorkflowCancelled(): ...
}

WorkflowRunner is reused across multiple run() calls in the usual pattern (declared once in a ViewModel) β€” call WorkflowRunner.dispose() from onDispose() to release the underlying ValueNotifier backing progress (see "WorkflowRunner.progress" below).

CancellationToken β€” Cancelling Mid-Flight #

Every run() carries a CancellationToken β€” created automatically if you don't pass one, or supplied yourself so you can cancel it from outside (e.g. a ViewModel's onDispose). Cancellation is cooperative: a step checks it between units of work, it can't interrupt a single await mid-flight.

final token = CancellationToken();
final resultFuture = signupWorkflow.run(context, cancellationToken: token);

// Later, e.g. from onDispose():
token.cancel();

Inside a step:

execute: (context, token) async {
  for (final chunk in chunks) {
    await uploadChunk(chunk);
    token.throwIfCancelled(); // stops between chunks, not mid-upload
  }
  return const StepSuccess();
}

A cancelled run resolves as WorkflowCancelled, not WorkflowFailure. WorkflowStep.onDeactivateOrCancel gives the actively-running step an immediate cleanup hook the instant cancel() fires, regardless of whether execute() has resolved yet β€” WorkflowStep.sustained (see "Step Definitions" below) builds directly on this for steps with no natural completion of their own.

WorkflowListener β€” Observing a Run #

Attach a WorkflowListener<TContext> to a WorkflowRunner to observe its lifecycle from the outside β€” step start/success/failure, rollback, and every successful context.write(...) β€” without any step needing to log anything itself:

class AnalyticsListener extends WorkflowListener<SignupContext> {
  @override
  void onWorkflowStart(SignupContext context) => analytics.track('signup_started');

  @override
  void onStepFailure(String stepId, Object error, StackTrace stackTrace, SignupContext context) =>
      analytics.track('signup_step_failed', {'step': stepId, 'error': '$error'});

  @override
  void onWorkflowSuccess(SignupContext context) => analytics.track('signup_completed');

  @override
  void onContextWrite(String key, Object? value, String? writerStepId, SignupContext context) =>
      analytics.track('flow_context_write', {'key': key, 'writerStepId': writerStepId});
}

final signupWorkflow = WorkflowRunner<SignupContext>(steps: [...], listener: AnalyticsListener());

Every hook has a no-op default, so a listener only overrides what it actually cares about: onWorkflowStart/onWorkflowSuccess/onWorkflowFailure, onStepStart/onStepSuccess/ onStepFailure/onStepSkip, the rollback trio (onStepRollbackStart/Success/Failure), and onContextWrite β€” fired after every successful context.write(...) made during the run, with writerStepId the id of whichever step made it (or null for a write made with no step active β€” see "FlowContext β€” Scoped Immutability" above). Doesn't fire for a write rejected by scoped immutability (the StateError case) β€” only successful writes. Useful for centralized logging, analytics, or a live debug/flow-visualizer screen, decoupled from the steps' own business logic.


πŸͺΆ Step Definitions #

WorkflowStep.action β€” Functional Steps #

Subclassing WorkflowStep<T> is still the right call for steps with their own state or that get reused across flows, but for a step that's just a few lines, WorkflowStep.action(...) skips the class entirely β€” it's fully interoperable with every decorator/composite below, since it implements the exact same WorkflowStep interface a subclass would:

final signupWorkflow = WorkflowRunner<SignupContext>(
  steps: [
    WorkflowStep<SignupContext>.action(
      id: 'create_account',
      execute: (context, token) async {
        context.userId = await authApi.createAccount(context.email, context.password);
        return const StepSuccess();
      },
      rollback: (context) async {
        if (context.userId != null) await authApi.deleteAccount(context.userId!);
      },
    ),
    SendWelcomeEmailStep(),
  ],
);

execute is required; rollback, canRun, and onDeactivateOrCancel are all optional, mirroring the methods you'd otherwise override on a subclass.

WorkflowStep.sustained β€” Work With No Natural Completion #

A common screen-level pattern: only one active subscription should ever be alive at a time. Start watching when the screen is shown, stop when it's paused or disposed, and if the target changes (e.g. switching chat rooms), cancel the previous subscription before starting the new one β€” never let two run concurrently.

Model the subscription itself as a WorkflowStep.sustained β€” a step whose work has no natural completion of its own. Unlike a regular step, it doesn't resolve once start returns; it keeps running until the step is cancelled or deactivated, at which point stop tears it down. Neither start nor stop ever see a raw CancellationToken β€” cancellation is exactly what ends the step, so there's nothing left to poll; stop is the reaction to it. The only thing start can actively report is failure, via the fail callback it's handed (e.g. a stream's onError) β€” there's no matching "succeed now", since a step with no natural completion has no natural "succeeded now" moment:

class OrderTrackingContext extends FlowContext {
  final String orderId;
  final void Function(OrderStatus) onStatusChanged;
  StreamSubscription<OrderStatus>? subscription;
  OrderTrackingContext({required this.orderId, required this.onStatusChanged});
}

final watchOrderStatusStep = WorkflowStep<OrderTrackingContext>.sustained(
  id: 'watch_order_status',
  start: (context, fail) {
    context.subscription = orderRepository.watchStatus(context.orderId).listen(context.onStatusChanged, onError: fail);
  },
  stop: (context) => context.subscription?.cancel(),
);

See "ManagedWorkflowRunner + ConcurrencyStrategy" under Concurrency below for how to pair this with cancelExisting() to get "cancel the old subscription, start the new one" bookkeeping for free.

InteractiveStep β€” Pausing for External Input #

A step that needs to pause and wait for something outside the workflow β€” most commonly, a user filling out a form on screen β€” doesn't need to hand-roll a Completer. Subclass InteractiveStep<TContext, TInput> instead:

// TInput is Object? here since this step doesn't need a payload β€” `void`
// isn't well-formed as a generic type argument used in a parameter position.
class BasicInfoStep extends InteractiveStep<SignupContext, Object?> {
  final SignupViewModel viewModel;
  BasicInfoStep(this.viewModel);

  @override
  String get id => 'basic_info';

  @override
  void onActivate(SignupContext context, CancellationToken token) {
    // Called once, as this step becomes active β€” trigger navigation here.
    viewModel.navigateToBasicInfoScreen();
  }

  @override
  StepResult onSubmit(SignupContext context, Object? input, CancellationToken token) {
    // Called when WorkflowRunner.submit('basic_info') resolves this step.
    return const StepSuccess();
  }
}

The page's own ViewModel resumes the workflow from the outside once the user submits the form:

void onFormSubmitted() {
  coordinator.context.email = emailController.text;
  coordinator.context.password = passwordController.text;
  coordinator.workflowRunner.submit('basic_info');
}

WorkflowRunner.submit(stepId, [input]) routes to that step's onSubmit; WorkflowRunner.fail(stepId, error) routes to onFail (a plain StepFailure by default). Both throw StateError if stepId isn't the step the engine is actually waiting on β€” including the case where it is the active step but isn't an InteractiveStep β€” so a stray or duplicate call can never resolve the wrong step, or the same step twice. While paused, StepStatus.awaitingInput flows through WorkflowRunner.progress just like any other status (see "WorkflowRunner.progress" below), so a step indicator can render "waiting on you" differently from "running."

If the workflow is cancelled while a step is paused, the engine resolves it immediately as a cancelled failure β€” no step author code is needed for this, unlike a hand-rolled Completer where you'd have to remember to wire CancellationToken.onCancel yourself.


🧩 Composites & Decorators #

All of these are themselves a WorkflowStep, so they compose freely with each other and with the plain steps from "Step Definitions" above.

ConditionalStep β€” Branching #

Wraps another step so it only runs if a predicate over the context is true β€” skipped, not failed, otherwise:

ConditionalStep(
  id: 'conditional_upload_avatar',
  condition: (ctx) => ctx.hasAvatar,
  step: UploadAvatarStep(),
)

A skipped step reports StepStatus.skipped through WorkflowRunner.progress and never runs its rollback β€” which must already be a safe no-op if the step never ran, the same convention every composite in this package relies on.

WorkflowStepGroup β€” Sub-Pipelines #

WorkflowRunner isn't itself a WorkflowStep, so it can't be nested directly. Use WorkflowStepGroup to embed a whole sub-pipeline as a single step in a parent workflow β€” useful for splitting a large flow into smaller, named, independently testable pieces.

final signupWorkflow = WorkflowRunner<SignupContext>(
  steps: [
    RetryStepDecorator(step: CreateAccountStep(), maxAttempts: 3),
    WorkflowStepGroup(
      id: 'profile_setup',
      steps: [
        ConditionalStep(
          id: 'conditional_upload_avatar',
          condition: (ctx) => ctx.hasAvatar,
          step: UploadAvatarStep(),
        ),
        ConditionalStep(
          id: 'conditional_apply_referral',
          condition: (ctx) => ctx.hasReferralCode,
          step: ApplyReferralCodeStep(),
        ),
      ],
    ),
    SendWelcomeEmailStep(),
  ],
);

If a step inside profile_setup fails, the group rolls back its own already-succeeded inner steps first, then reports the failure up as if profile_setup itself were the failed step. If profile_setup succeeds but SendWelcomeEmailStep fails afterwards, the parent rolls the group back too β€” which rolls back every inner step it ran, in reverse order, same LIFO contract as the top-level runner.

ParallelStepGroup β€” Concurrent Sub-Steps #

The concurrent counterpart to WorkflowStepGroup: runs subSteps concurrently as a single step instead of sequentially through a nested runner. Succeeds only if every sub-step succeeds or is skipped; fails with the first failure encountered (the others are still awaited to completion, per Future.wait's default behavior, before this step resolves):

ParallelStepGroup(
  id: 'fetch_dashboard_data',
  subSteps: [FetchProfileStep(), FetchNotificationsStep()],
  listener: DashboardListener(), // optional β€” forwards each sub-step's own lifecycle events
)

The parent WorkflowRunner only ever tracks this group's own id, not each sub-step individually β€” pass listener if a UI needs live status per sub-step (its onStepStart/onStepSuccess/onStepFailure/onStepSkip fire with each sub-step's own id as they happen).

Known limitation: unlike WorkflowStepGroup, sub-steps here execute against the same FlowContext active-step slot rather than each getting its own turn β€” every write any sub-step makes while the group executes is attributed to the group's own id, not the individual sub-step that made it. Give concurrent sub-steps disjoint context keys.

RetryStepDecorator β€” Auto-Retry with Backoff #

Wraps a brittle step to retry it automatically, with a configurable exponential backoff:

RetryStepDecorator(
  step: CreateAccountStep(),
  maxAttempts: 3,
  initialDelay: const Duration(seconds: 1),
  backoffFactor: 2.0,           // each retry waits longer than the last
  retryIf: (error) => error is! ValidationException, // optional β€” skip retrying some errors
)

Retries on a StepFailure result or a thrown exception from execute(); gives up and reports the last failure once maxAttempts is reached, or immediately if retryIf returns false for that error. Delegates rollback/onDeactivateOrCancel straight to the wrapped step.

TimeoutStepDecorator β€” Bounding Duration #

Wrap any step to fail it β€” with a regular StepFailure, not a workflow-wide cancellation β€” if it runs longer than expected. This composes with RetryStepDecorator: retry a handful of times, each attempt bounded by its own timeout.

final signupWorkflow = WorkflowRunner<SignupContext>(
  steps: [
    RetryStepDecorator(
      step: TimeoutStepDecorator(
        step: CreateAccountStep(),
        timeout: const Duration(seconds: 5),
      ),
      maxAttempts: 3,
    ),
    SendWelcomeEmailStep(),
  ],
);

A step wrapped this way still sees cancellation from the parent workflow's token as normal β€” but a timeout itself only fails that one step (letting it stop any in-flight work, e.g. abort an HTTP call) without cancelling the rest of the workflow.


πŸ“ Progress #

WorkflowRunner.progress β€” Live Step Status #

Every WorkflowRunner exposes a progress ValueListenable<WorkflowProgress>, updated synchronously as the run proceeds β€” no extra wiring through WorkflowListener required. WorkflowProgress holds the id of the step currently executing (currentStepId, null before the run starts or after it finishes) and the last known StepStatus of every step reached so far (pending, running, awaitingInput β€” an InteractiveStep paused for external input, see above β€” success, failed, skipped, rollbackRunning, rollbackSuccess, or rollbackFailed).

final signupWorkflow = WorkflowRunner<SignupContext>(steps: [...]);

// Feed it straight into a ValueListenableBuilder to render a step indicator:
ValueListenableBuilder<WorkflowProgress>(
  valueListenable: signupWorkflow.progress,
  builder: (context, progress, _) {
    return Row(
      children: [
        for (final step in signupWorkflow.steps)
          _StepDot(status: progress.statusOf(step.id) ?? StepStatus.pending),
      ],
    );
  },
);

WorkflowRunner is reused across multiple run() calls in the usual pattern (declared once in a ViewModel), so progress resets to pending for every step at the start of each new run β€” it always reflects the most recent run, never a stale one. Call WorkflowRunner.dispose() from onDispose() to release the underlying ValueNotifier, same as any other ChangeNotifier-based resource.

ManagedWorkflowRunner/SharedWorkflowRunner (below) build a fresh WorkflowRunner per logical run instead of wrapping one fixed instance, so they don't expose a single progress themselves β€” ParallelRunHandle (from ParallelWorkflowRunner) exposes its own invocation's runner.progress directly, which is the only place a "current progress" concept is actually well-defined once more than one logical run can exist.


πŸ”€ Concurrency #

For "what happens if run() is triggered again while already running" β€” three wrapper classes cover five required behaviors between them.

ManagedWorkflowRunner + ConcurrencyStrategy #

Covers three of the five behaviors β€” ignore, cancelExisting, enqueue β€” all sharing the same "exactly one logical run owns the runner at a time" shape. Pick one at construction:

ManagedWorkflowRunner<TContext>({
  required WorkflowRunnerFactory<TContext> createRunner,
  required ConcurrencyStrategy strategy,
});
  • ConcurrencyStrategy.ignore() β€” swallow the new call entirely; the in-flight run continues untouched, and the caller gets back that same run's Future (their own context argument is never used if a call gets swallowed).

  • ConcurrencyStrategy.cancelExisting() β€” cancel the old run, wait for it to fully settle (rollback included), then start fresh. Pairs naturally with WorkflowStep.sustained (see "Step Definitions" above) for "only one active subscription at a time" screens:

    class OrderTrackingController {
      final _watchOrder = ManagedWorkflowRunner<OrderTrackingContext>(
        createRunner: () => WorkflowRunner(steps: [watchOrderStatusStep]),
        strategy: const ConcurrencyStrategy.cancelExisting(),
      );
    
      void watchOrder(String orderId) {
        // Cancels whatever subscription is currently active β€” waiting for it to
        // fully settle, rollback included β€” before starting this one.
        _watchOrder.run(OrderTrackingContext(orderId: orderId, onStatusChanged: _handleStatusChanged));
      }
    
      // Wire these to whatever lifecycle hooks your screen/controller already
      // has (e.g. a StatefulWidget's didChangeAppLifecycleState, or a
      // ViewModel's onResume/onPause if you're using maac_mvvm).
      void onScreenVisible() {
        watchOrder(currentOrderId); // (Re)start the subscription.
      }
    
      void onScreenHidden() {
        _watchOrder.cancel(); // Stop it. onScreenVisible() restarts it.
      }
    }
    
  • ConcurrencyStrategy.enqueue({int? maxQueueLength}) β€” queue the new call; it runs once every call ahead of it has finished (success, failure, or cancellation), strictly FIFO. Exceeding maxQueueLength rejects only the excess call β€” like every other outcome in this package, by resolving immediately with a WorkflowFailure carrying a QueueFullException (never a thrown exception) β€” without disturbing the existing queue.

All three share the same run(context) -> Future<WorkflowResult<TContext>> shape, so switching strategies at a call site is just swapping the strategy: argument. It takes a factory, not a fixed WorkflowRunner instance β€” a fresh runner (and fresh step instances) is built for every logical run, since WorkflowRunner/InteractiveStep hold per-instance mutable state that two overlapping runs can never safely share.

ParallelWorkflowRunner #

Covers parallel β€” no "one current run" concept. Every call spins up a fully independent run β€” its own WorkflowRunner, its own isolated FlowContext β€” running concurrently with any others rather than cancelling or queuing them:

final uploads = ParallelWorkflowRunner<UploadContext>(
  createRunner: () => WorkflowRunner(steps: [UploadFileStep()]),
);

final handle = uploads.run(UploadContext(file: picked));
// `run()` returns a handle synchronously β€” inspect this specific invocation's
// own progress without any cross-run aggregation:
ValueListenableBuilder<WorkflowProgress>(
  valueListenable: handle.progress,
  builder: (context, progress, _) => UploadProgressBar(progress),
);

await handle.result;               // this invocation's own outcome
uploads.activeRuns;                // every upload still in flight right now

SharedWorkflowRunner + JoinCompletionRule #

Covers joinOrCreate: if nothing is running, starts a new session; if one's already in flight (e.g. a global loading indicator), merges the new call into it instead of starting a second physical run β€” avoiding duplicate work and UI flicker:

final shared = SharedWorkflowRunner<SyncContext>(
  createRunner: () => WorkflowRunner(steps: [SyncStep()]),
  rule: JoinCompletionRule.waitAll,
);

final handle = shared.join(SyncContext());
// Bind a shared loading indicator to this β€” same instance across every joiner:
shared.isSessionActive;

await handle.result;   // the session's one physical run β€” identical Future for every joiner
handle.leave();         // call from teardown, e.g. onDispose

There's exactly one physical run per session β€” every joiner's result is the identical Future, resolving at one instant for everyone. JoinCompletionRule doesn't change when the real work finishes; it governs when isSessionActive closes once multiple joiners are attached, and whether closing early also cancels the still-in-flight run:

  • waitAll β€” reference-counted: the session stays active until every joiner that attached has called leave(). The only rule where the run is never cut short by a joiner leaving early. A joiner who never calls leave() leaves the session open forever β€” fully your responsibility to avoid, there's no automatic timeout.
  • overrideByLatest β€” the most recently joined caller owns the session. When that joiner leaves, the session closes immediately and cancels the run if still in flight, regardless of whether earlier joiners are still attached β€” their result resolves as WorkflowCancelled at that point.
  • firstWins β€” the very first leave() from any joiner closes the session immediately and cancels the run if still in flight; every other joiner's leave() becomes a no-op once that happens.

If any joiner needs the real success/failure outcome rather than just a "something happened" signal, use waitAll. Under the other two rules, an early close can turn every attached joiner's result into WorkflowCancelled regardless of how the work would otherwise have turned out β€” appropriate for a pure UI signal (a shared spinner), not for a result multiple callers actually depend on.


πŸ—ΊοΈ Where to Go Next #

  • Example app β†’ Basics (packages/maac_workflow/example) β€” one minimal, focused screen per concept documented above, deliberately bare of UI chrome so each one reads as "just the API".
  • Example app β†’ Signup / Sequential API / Single-Flight flows β€” fuller showcases combining several concepts into one realistic screen.

🧭 Documentation #

For the wider MAAC ecosystem this package is part of β€” install guides, architecture philosophy, and specs for the other MAAC packages β€” see the centralized documentation hub:

πŸ‘‰ MAAC Documentation Hub

Specific Guides: #


🀝 Contributing #

Contributions are welcome! Please visit the main repository for more information.

0
likes
160
points
0
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

maac_workflow is a resilient, stateful, and declarative asynchronous workflow and step pipeline engine for Dart & Flutter.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

async, flutter, meta

More

Packages that depend on maac_workflow