Skip to content

UserTaskExecutor API

Base class for user task executors. User tasks require human interaction and always wait for a signal.

UserTaskExecutor

dart
abstract class UserTaskExecutor implements SchemaItem {
  /// Unique schema type identifier for registry lookup
  /// Example: 'userTask.approval', 'userTask.review'
  String get schemaType;

  /// Human-readable name
  String get name;

  /// Optional description
  String get description => '';

  /// Execute - ALWAYS returns WaitForUserTaskResult
  Future<WaitForUserTaskResult> execute(ExecutionContext context);

  /// Process user's output before storing (optional override)
  Map<String, dynamic> processOutput(Map<String, dynamic> userOutput) =>
      userOutput;

  /// Validate task configuration (optional)
  List<String> validateConfig(Map<String, dynamic> config) => [];
}

TypedUserTaskExecutor

Type-safe base class with compile-time safety for input:

dart
abstract class TypedUserTaskExecutor<TInput> extends UserTaskExecutor {
  /// Deserialize input from workflow variables
  TInput fromInput(Map<String, dynamic> input);

  /// Type-safe execute method
  Future<WaitForUserTaskResult> executeTyped(
    TInput input,
    ExecutionContext context,
  );
}

WaitForUserTaskResult

Result returned by user task executors:

dart
class WaitForUserTaskResult extends WaitForSignalResult {
  const WaitForUserTaskResult({
    required super.signalName,
    required this.config,
    super.timeout,
    this.effects = const [],  // NEW: Side effects before creating task
  });

  /// Configuration for the user task instance
  final UserTaskConfiguration config;

  /// Effects to apply before creating the user task
  /// Common use: Cancel pending tasks before creating new ones
  final List<WorkflowEffect> effects;
}

UserTaskConfiguration

Specification for creating a user task instance:

dart
class UserTaskConfiguration {
  const UserTaskConfiguration({
    required this.title,
    required this.schemaType,
    this.description,
    this.assignedToUserId,
    this.assignedToRoleId,
    this.assignedToGroupId,
    this.priority = UserTaskPriority.normal,
    this.dueDate,
    this.input,
    this.storeAs,
  });

  final String title;
  final String schemaType;
  final String? description;
  final String? assignedToUserId;
  final String? assignedToRoleId;
  final String? assignedToGroupId;
  final UserTaskPriority priority;
  final DateTime? dueDate;
  final Map<String, dynamic>? input;
  final String? storeAs;
}

UserTaskPriority

dart
enum UserTaskPriority {
  low,
  normal,
  high,
  urgent;

  factory UserTaskPriority.fromString(String value);
}

Key Difference from TaskExecutor

dart
// TaskExecutor - returns immediately with result
abstract class TaskExecutor {
  Future<TaskResult> execute(ExecutionContext context);
  // Returns: TaskSuccess or TaskFailure
}

// UserTaskExecutor - ALWAYS waits for human signal
abstract class UserTaskExecutor {
  Future<WaitForUserTaskResult> execute(ExecutionContext context);
  // Returns: ALWAYS WaitForUserTaskResult
}

Examples

Basic UserTaskExecutor

dart
class ApprovalTaskExecutor extends UserTaskExecutor {
  static const _schemaType = 'userTask.approval';

  static final typeDescriptor = TypeDescriptor<UserTaskExecutor>(
    schemaType: _schemaType,
    fromJson: (json) => ApprovalTaskExecutor(),
    title: 'Approval Task',
  );

  @override
  String get schemaType => _schemaType;

  @override
  String get name => 'Approval Task';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    // Get data from previous node output
    final entityType = context.getRequired<String>('entityType');
    final roleId = context.getRequired<String>('approverRoleId');

    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: 'Approve $entityType',
        description: 'Please review and approve this ${entityType.toLowerCase()}',
        schemaType: schemaType,
        assignedToRoleId: roleId,
        priority: UserTaskPriority.high,
        input: context.input,
      ),
    );
  }
}

With Effects (Cancel Previous Tasks)

dart
class RevisionTaskExecutor extends UserTaskExecutor {
  @override
  String get schemaType => 'userTask.revision';

  @override
  String get name => 'Revision Task';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    final submitterId = context.getRequired<String>('submitterId');

    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: 'Revise Document',
        schemaType: schemaType,
        assignedToUserId: submitterId,
      ),
      // Cancel any pending approval tasks before creating revision task
      effects: [const CancelUserTasksEffect()],
    );
  }
}

TypedUserTaskExecutor

dart
@JsonSerializable()
class ApprovalTaskInput {
  final String entityId;
  final String entityType;
  final int currentLevel;
  final String currentLevelRoleId;

  ApprovalTaskInput({
    required this.entityId,
    required this.entityType,
    required this.currentLevel,
    required this.currentLevelRoleId,
  });

  factory ApprovalTaskInput.fromJson(Map<String, dynamic> json) =>
      _$ApprovalTaskInputFromJson(json);

  Map<String, dynamic> toJson() => _$ApprovalTaskInputToJson(this);
}

class ApprovalTaskExecutor
    extends TypedUserTaskExecutor<ApprovalTaskInput> {
  static const _schemaType = 'userTask.approval';

  static final typeDescriptor = TypeDescriptor<UserTaskExecutor>(
    schemaType: _schemaType,
    fromJson: (json) => ApprovalTaskExecutor(),
    title: 'Approval Task',
  );

  @override
  String get schemaType => _schemaType;

  @override
  String get name => 'Approval Task';

  @override
  ApprovalTaskInput fromInput(Map<String, dynamic> input) =>
      ApprovalTaskInput.fromJson(input);

  @override
  Future<WaitForUserTaskResult> executeTyped(
    ApprovalTaskInput input,
    ExecutionContext context,
  ) async {
    // Fully typed access to input!
    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: 'Approve ${input.entityType}: Level ${input.currentLevel}',
        description: 'Review and approve this ${input.entityType}',
        schemaType: schemaType,
        assignedToRoleId: input.currentLevelRoleId,
        priority: UserTaskPriority.high,
        input: input.toJson(),  // Pass typed data for UI
        storeAs: 'approvalDecision',
      ),
    );
  }
}

With Output Processing

dart
class ReviewTaskExecutor extends UserTaskExecutor {
  @override
  String get schemaType => 'userTask.review';

  @override
  String get name => 'Review Task';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: 'Review Document',
        schemaType: schemaType,
        assignedToRoleId: 'reviewers',
        storeAs: 'reviewResult',
      ),
    );
  }

  @override
  Map<String, dynamic> processOutput(Map<String, dynamic> userOutput) {
    // Transform or enrich user output before storing
    return {
      ...userOutput,
      'processedAt': DateTime.now().toIso8601String(),
      'reviewComplete': true,
    };
  }
}

DefaultUserTaskExecutor

Built-in executor that resolves template variables from node configuration:

dart
class DefaultUserTaskExecutor extends UserTaskExecutor {
  static const _schemaType = 'userTask.default';

  @override
  String get schemaType => _schemaType;

  @override
  String get name => 'Default User Task';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    // Parses UserTaskNodeConfiguration and resolves {{variable}} templates
    final nodeConfig = UserTaskNodeConfiguration.fromJson(context.config);

    // Templates like {{entityType}} are resolved from workflow output
    final title = _resolveTemplate(nodeConfig.title, context.input);

    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: title ?? 'User Task',
        schemaType: nodeConfig.schemaType,
        assignedToRoleId: _resolveTemplate(nodeConfig.assignToRole, context.input),
        // ... other resolved fields
      ),
    );
  }
}

Registration

dart
final descriptor = WorkflowDescriptor(
  title: 'User Tasks',
  userTasks: [
    ApprovalTaskExecutor.typeDescriptor,
    ReviewTaskExecutor.typeDescriptor,
  ],
);

// Create context with descriptors
final context = RegistryDeserializationContext(
  descriptors: [DefaultWorkflowDescriptor(), descriptor],
);

// Create engine with context and storage
final engine = WorkflowEngine(
  context: context,
  storage: InMemoryStorage(context: context),
);
await engine.initialize();

Comparison

FeatureUserTaskExecutorTypedUserTaskExecutor
Input accesscontext.get<T>('key')input.key
Return typeWaitForUserTaskResultWaitForUserTaskResult
Type safetyRuntimeCompile-time
SerializationManualVia fromInput
EffectsYes, via effects listSame

Best Practices

  1. Use namespaced schema types - userTask.domain.action format
  2. Prefer TypedUserTaskExecutor for complex input
  3. Set appropriate priority based on urgency
  4. Include context in input for UI rendering
  5. Use effects to cancel previous tasks - Before creating revision/resubmit tasks
  6. Use get<T> for previous node output - This is your primary data source
  7. Use getInitial<T> for original workflow input - For configuration that persists

See Also