Skip to main content

Handling User Tasks

User Tasks enable a ThreadRun to block until a human user provides some input into the workflow. Additionally, User Tasks have several useful hooks such as automatic reassignment, reminders, and auditing capabilities.

This page shows you how to interact with a UserTaskRun on an already-running WfRun.

tip

For documentation about how to insert a USER_TASK node into your WfSpec, please refer to the WfSpec development documentation.

Note that LittleHorse does not provide an out-of-the-box implementation of a User Task Manager application. This is because it would likely be of limited use to our users, because the implementation of User Task Applications is highly use-case specific. For example, each of the following considerations might be handled vastly differently depending on the application:

  • Presentation: is the user task form presented in a mobile app, standalone internal web application, or embedded into a customer-facing site?
  • Identity Management: what system is used to manage and determine the identity of the person executing User Tasks?
  • Look and Feel: what is the style of the actual page?
  • Access Permisions: while the userGroup field of a UserTaskRun is useful for determining who may execute a UserTaskRun, how should the Task Manager application determine who can perform additional acctions, such as reassignment and viewing audit information?

While those considerations are left to the user of LittleHorse, User Tasks still provide an incredibly valuable tool to our users, specifically:

  • Direct integrations with the WfSpec
  • Built-in reassignment and reminder capabilities
  • Built-in search capabilities.

This documentation explains everything you need in order to build your own application-specific User Tasks integration.

UserTaskRun Lifecycle

In order to use User Tasks, you must first create a WfSpec that has a USER_TASK node in it, for example by using the WorkflowThread#assignUserTask() method (see our WfSpec Development Docs).

When a ThreadRun arrives at such a Node, then a UserTaskRun object is created in the LittleHorse Data Store. The ThreadRun will "block" at that Node until the UserTaskRun is either completed or cancelled. When the UserTaskRun is completed, the NodeRun returns an output which is a JSON_OBJ containing a key-value pair for every field in the UserTaskDef (in plain English, this is just one key-value for each field in the User Task form). When the UserTaskRun is cancelled, an EXCEPTION is propagated to the ThreadRun.

The only way to Complete a UserTaskRun is via the rpc CompleteUserTaskRun endpoint on the LH Server. A UserTaskRun may be cancelled either by the rpc CancelUserTaskRun or by lifecycle hooks built-in to the WfSpec.

A UserTaskRun may be in one of the four statuses below:

  • UNASSIGNED: the UserTaskRun does not have a specific user_id set. In this case, user_group must be set.
  • ASSIGNED: the UserTaskRun has a specific user_id set, and may have a user_group set as well.
  • DONE: the UserTaskRun has been completed.
  • CANCELLED: the UserTaskRun has been cancelled either by a manual rpc CancelUserTaskRun or by a built-in User Task lifecycle hook.

Search for UserTaskRuns

Before you can do anything useful with User Tasks, you need to be able to search for a list of UserTaskRun's matching certain criteria. The endpoint rpc SearchUserTaskRun allows you to do this.

You can find the documentation for rpc SearchUserTaskRun here in our API documentation.

There are six filters that can be provided:

  • status: an enum of either DONE, UNASSIGNED, ASSIGNED, or CANCELLED.
  • user_task_def_name: the name of the associated UserTaskDef.
  • user_id: Only returns UserTaskRun's assigned to a specific user.
  • user_group: only returns UserTaskRun's assigned to a group.
  • earliest_start: only returns UserTaskRun's created after this date.
  • latest_start: only returns UserTaskRun's created before this date.

All fields are additive; meaning that you can specify any combination of the fields, and only UserTaskRun's matching all of the criteria will be

info

The user_id and user_group fields are not managed by LittleHorse. Rather, they are intended to allow the user of LittleHorse to pass values managed by an external identity provider. This allows User Tasks to support a wide array of identity management systems.

See below for an example of searching for UserTaskRun's with the following criteria:

  • Assigned to the jedi-council group, and specifically to be executed by obiwan
  • Created in the past week but at least 24 hours ago.
  • In the ASSIGNED status.
  • Of the type approve-funds-for-mission.
package io.littlehorse.quickstart;

import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.google.protobuf.Timestamp;
import io.littlehorse.sdk.common.LHLibUtil;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.SearchUserTaskRunRequest;
import io.littlehorse.sdk.common.proto.UserTaskRunIdList;
import io.littlehorse.sdk.common.proto.UserTaskRunStatus;


public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

Timestamp oneWeekAgo = Timestamp.newBuilder()
.setSeconds(Instant.now().minus(7, ChronoUnit.DAYS).getEpochSecond())
.build();

Timestamp oneDayAgo = Timestamp.newBuilder()
.setSeconds(Instant.now().minus(7, ChronoUnit.DAYS).getEpochSecond())
.build();

// You can omit certain search criteria here if desired. Only one criterion is needed
// but you may provide as many criteria as you wish. This request shows all of the
// available search criteria.
//
// Note that it is a paginated request as per the "Basics" section of our grpc docs.
SearchUserTaskRunRequest req = SearchUserTaskRunRequest.newBuilder()
.setUserId("obiwan")
.setUserGroup("jedi-council")
.setUserTaskDefName("it-request")
.setStatus(UserTaskRunStatus.ASSIGNED)
.setEarliestStart(oneWeekAgo)
.setLatestStart(oneDayAgo)
.build();

UserTaskRunIdList results = client.searchUserTaskRun(req);
System.out.println(LHLibUtil.protoToJson(results));

// Omitted: process the UserTaskRunIdList, maybe using pagination.
}
}

Display a UserTaskRun

Now that you've found some relevant UserTaskRun's that you want to display in your application, how do you show them? This is particularly important to understand when building a generic User Task Manager.

First, the rpc GetUserTaskRun request can be used to get the details for of a UserTaskRun. To use this request, you need a UserTaskRunId (see the note on searching above!).

Once you have the UserTaskRun, you can inspect the results (if it's already completed). If you are trying to develop a frontend to execute the UserTaskRun, you can iterate through the fields of the UserTaskDef and determine what fields are to be displayed.

package io.littlehorse.quickstart;

import java.io.IOException;
import java.util.Map;

import io.littlehorse.sdk.common.LHLibUtil;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.UserTaskDef;
import io.littlehorse.sdk.common.proto.UserTaskField;
import io.littlehorse.sdk.common.proto.UserTaskRun;
import io.littlehorse.sdk.common.proto.UserTaskRunId;
import io.littlehorse.sdk.common.proto.UserTaskRunStatus;
import io.littlehorse.sdk.common.proto.VariableValue;
import io.littlehorse.sdk.common.proto.WfRunId;


public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

// Get a UserTaskRunId somehow. For example, you could search for one as shown
// in the section above.
UserTaskRunId id = UserTaskRunId.newBuilder()
.setWfRunId(WfRunId.newBuilder().setId("e0e49b53298a4965b059a1a5df095b09"))
.setUserTaskGuid("8bb5d43e14894c82bb1deab7a68b32ae")
.build();

// Fetch the UserTaskRun
UserTaskRun userTaskRun = client.getUserTaskRun(id);

// See the current owners
String userId = userTaskRun.hasUserId() ? userTaskRun.getUserId() : null;
String userGroup = userTaskRun.hasUserGroup() ? userTaskRun.getUserGroup() : null;
System.out.println(
"The UserTaskRun is assigned to group '%s' and user '%s'".formatted(userGroup, userId));

// In order to see the fields, you need to fetch the `UserTaskDef`.
UserTaskDef utd = client.getUserTaskDef(userTaskRun.getUserTaskDefId());
for (UserTaskField field : utd.getFieldsList()) {
System.out.println("Field %s has type %s".formatted(field.getName(), field.getType()));
}

// If the UserTaskRun is in the `DONE` state, it will have `results`.
if (userTaskRun.getStatus() == UserTaskRunStatus.DONE) {
for (Map.Entry<String, VariableValue> resultEntry : userTaskRun.getResultsMap().entrySet()) {
System.out.println(
resultEntry.getKey() +
": " +
LHLibUtil.protoToJson(resultEntry.getValue()));
}
}
}
}

Complete a UserTaskRun

To complete a UserTaskRun, you can use the rpc CompleteUserTaskRun. The protobuf for the call is as follows:

rpc CompleteUserTaskRun(CompleteUserTaskRunRequest) returns (google.protobuf.Empty) {}

The CompleteUserTaskRunRequest message is defined as follows:

message CompleteUserTaskRunRequest {
UserTaskRunId user_task_run_id = 1;
map<string, VariableValue> results = 2;
string user_id = 3;
}

You can also consult our autogenerated API documentation for the rpc CompleteUserTaskRun or for message CompleteUserTaskRunRequest.

The first field is the UserTaskRunId of the UserTaskRun which you intend to complete. The second is a map where each key is the name of a field in the UserTaskDef, and the value is a VariableValue representing the value of that User Task Field. The user_id field must be set and is the user_id of the person completing the UserTaskRun.

The current behavior of the user_id field is that, if it differs from the current owner of the UserTaskRun, then the UserTaskRun will be re-assigned to the new user_id. We have an open ticket to make this behavior configurable. If this is an important feature for you, please comment on the ticket! We're happy to bump its priority; alternatively, we do accept Pull Requests 😄.

In the examples below, the user obiwan will complete a UserTaskRun that has two fields: a STR field called requestedItem set to "lightsaber", and a STR field called justification.

package io.littlehorse.quickstart;

import java.io.IOException;
import io.littlehorse.sdk.common.LHLibUtil;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.CompleteUserTaskRunRequest;
import io.littlehorse.sdk.common.proto.UserTaskRunId;
import io.littlehorse.sdk.common.proto.WfRunId;


public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

// Get a UserTaskRunId somehow. For example, you could search for one as shown
// in the section above.
UserTaskRunId id = UserTaskRunId.newBuilder()
.setWfRunId(WfRunId.newBuilder().setId("e0e49b53298a4965b059a1a5df095b09"))
.setUserTaskGuid("8bb5d43e14894c82bb1deab7a68b32ae")
.build();

// Complete the UserTaskRun. The key of `putResults` is the `name` of the `UserTaskField`,
// and the value comes from the `LHLibUtil#objToVarVal()` method which is a convenience
// for creating a `VariableValue`.
client.completeUserTaskRun(CompleteUserTaskRunRequest.newBuilder()
.setUserId("obiwan") // if different than the current value, it will overwrite it.
.putResults("requestedItem", LHLibUtil.objToVarVal("lightsaber"))
.putResults("justification", LHLibUtil.objToVarVal("Darth Maul kicked it down the mine shaft"))
.setUserTaskRunId(id)
.build());
}
}

Save a UserTaskRun

Sometimes, when a user is working on a User Task, they might want to save their progress without completing it. The rpc SaveUserTaskRunProgress allows you to do this.

Saving the progress of a User Task does the following:

  1. Update the results field in the UserTaskRun object.
  2. Add a UserTaskEvent to the events field of the UserTaskRun object denoting which user_id saved the progress, and what results were saved.

The policy field of the SaveUserTaskRunProgressRequest configures how to handle when the user_id of the person saving the progress differs from the user_id to whom the UserTaskRun is assigned.

  • FAIL_IF_CLAIMED_BY_OTHER: this is the default value for policy. If the user_id of the request does not match the user_id of the UserTaskRun, then the request fails with FAILED_PRECONDITION.
  • IGNORE_CLAIM: this value allows the caller to save the progress of a UserTaskRun even when user_id of the SaveUserTaskRunRequest differs from the user_id of the UserTaskRun.
note

The rpc SaveUserTaskRunProgress does NOT change the ownership of the UserTaskRun: the user_id field is not changed by this request.

package io.littlehorse.examples;

import java.io.IOException;

import io.littlehorse.sdk.common.LHLibUtil;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.SaveUserTaskRunProgressRequest;
import io.littlehorse.sdk.common.proto.SaveUserTaskRunProgressRequest.SaveUserTaskRunAssignmentPolicy;
import io.littlehorse.sdk.common.proto.UserTaskRunId;
import io.littlehorse.sdk.common.proto.WfRunId;

public class Main {

public static void main(String[] args) throws IOException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

// Get a UserTaskRunId somehow. For example, you could search for one as shown
// in the section above.
UserTaskRunId id = UserTaskRunId.newBuilder()
.setWfRunId(WfRunId.newBuilder().setId("e0e49b53298a4965b059a1a5df095b09"))
.setUserTaskGuid("8bb5d43e14894c82bb1deab7a68b32ae")
.build();

// In this example, we use the FAIL_IF_CLAIMED_BY_OTHER policy.
client.saveUserTaskRunProgress(SaveUserTaskRunProgressRequest.newBuilder()
.setUserTaskRunId(id)
// If the UserTaskRun is assigned to someone other than obi-wan this will fail
.setUserId("obi-wan")
.setPolicy(SaveUserTaskRunAssignmentPolicy.FAIL_IF_CLAIMED_BY_OTHER)
.putResults("some-field", LHLibUtil.objToVarVal("lightsaber"))
.build());
}
}

Re-Assign a UserTaskRun

When building a task manager application, you may wish to have an administrative panel in which an admin may assign or re-assign tasks to various people. To re-assign a UserTaskRun, you can use the request rpc AssignUserTaskRun. The request proto is as follows:

message AssignUserTaskRunRequest {
UserTaskRunId user_task_run_id = 1;

bool override_claim = 2;

optional string user_group = 3;
optional string user_id = 4;
}

If the override_claim field is set to false and the UserTaskRun is already assigned to a specific user_id, then the request will fail with FAILED_PRECONDITION.

It is important to note that the request will overwrite both the user_id and the user_group with the provided values from this request. If the UserTaskRun is currently assigned to user_group == 'sales' and user_id == null, and a client makes the following request:

{
user_task_run_id: ...,
override_claim: false,
user_group: null,
user_id: "sarah"
}

The UserTaskRun will be assigned to user_id: "sarah" and user_group: null. An example request is shown below.

package io.littlehorse.quickstart;

import java.io.IOException;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.AssignUserTaskRunRequest;
import io.littlehorse.sdk.common.proto.UserTaskRunId;
import io.littlehorse.sdk.common.proto.WfRunId;


public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

// Get a UserTaskRunId somehow. For example, you could search for one as shown
// in the section above.
UserTaskRunId id = UserTaskRunId.newBuilder()
.setWfRunId(WfRunId.newBuilder().setId("a7476518fdff4dd49f47dbe40df3c5a6"))
.setUserTaskGuid("709cac9fcd424d87810a6cabf66d400e")
.build();

// Reassign the UserTaskRun.
client.assignUserTaskRun(AssignUserTaskRunRequest.newBuilder()
.setUserId("mace-windu")
.setUserGroup("jedi-temple")
.setOverrideClaim(true)
.setUserTaskRunId(id)
.build());
}
}

Cancel a UserTaskRun

The last useful operation you may need to do when building an application using User Tasks is to "cancel" a UserTaskRun.

info

By default, when a UserTaskRun is cancelled, the NodeRun fails with an ERROR. However, in the WfSpec SDK's you can configure this behavior on a case-by-case basis. For example, you can override the behavior to throw a specific business EXCEPTION upon cancellation.

The request rpc CancelUserTaskRun is quite simple. The only edge-case is that the request throws FAILED_PRECONDITION if the UserTaskRun is already in the DONE status.

message CancelUserTaskRunRequest {
UserTaskRunId user_task_run_id = 1;
}

A simple example is shown below:

package io.littlehorse.quickstart;

import java.io.IOException;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.common.proto.CancelUserTaskRunRequest;
import io.littlehorse.sdk.common.proto.UserTaskRunId;
import io.littlehorse.sdk.common.proto.WfRunId;


public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();

// Get a UserTaskRunId somehow. For example, you could search for one as shown
// in the section above.
UserTaskRunId id = UserTaskRunId.newBuilder()
.setWfRunId(WfRunId.newBuilder().setId("a7476518fdff4dd49f47dbe40df3c5a6"))
.setUserTaskGuid("709cac9fcd424d87810a6cabf66d400e")
.build();

// Reassign the UserTaskRun.
client.cancelUserTaskRun(CancelUserTaskRunRequest.newBuilder()
.setUserTaskRunId(id)
.build());
}
}