[devicelab] Run tasks concurrently.

1. Add a job module to help create sub builds, which has a lot of customizations and simplifications than the fuchsia/subbuild module.

2. In the future we will add a builder that runs all devicelab tests for every flutter commit.

3. I have tried an optimization of isolating the flutter/flutter checkout to sub-jobs. It doesn't work because a sub-job needs 70 secs to download the isolate, while checking out flutter/flutter only takes 20 secs.

4: Example test: https://chromium-swarm.appspot.com/task?id=4d7414a646f94c10

Bug: https://github.com/flutter/flutter/issues/53789
Change-Id: I60c8da510384bfff3093580d90331ea57c0d8045
Reviewed-on: https://flutter-review.googlesource.com/c/recipes/+/4263
Reviewed-by: Godofredo Contreras <godofredoc@google.com>
Commit-Queue: Tong Wu <wutong@google.com>
diff --git a/recipe_modules/job/__init__.py b/recipe_modules/job/__init__.py
new file mode 100644
index 0000000..5f3ac86
--- /dev/null
+++ b/recipe_modules/job/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+DEPS = [
+    "recipe_engine/buildbucket",
+    "recipe_engine/file",
+    "recipe_engine/json",
+    "recipe_engine/led",
+    "recipe_engine/path",
+    "recipe_engine/properties",
+    "recipe_engine/step",
+    "recipe_engine/swarming",
+]
diff --git a/recipe_modules/job/api.py b/recipe_modules/job/api.py
new file mode 100644
index 0000000..1f2ab72
--- /dev/null
+++ b/recipe_modules/job/api.py
@@ -0,0 +1,133 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.protobuf import json_format
+from recipe_engine import recipe_api
+from PB.go.chromium.org.luci.buildbucket.proto import build as build_pb2
+
+
+class Job(object):
+  """Describes a job that could be launched via led.
+
+  Attributes:
+    name (str): Name of the job.
+    properties (dict): Properties as the arguments to `led edit -p`.
+    dimensions (dict): Dimensions as the arguments to `led edit -d`.
+    task_id (str): Id of the swarming task.
+    task_server (str): Host name of the swarming server, e.g.
+        "chromium-swarm.appspot.com".
+    task_result (api.swarming.TaskResult): Result of the swarming task.
+    build_proto(build_pb2.Build): Proto generated from a buildbucket build.
+    outcome (str): The outcome could be "success", "none" when task_result.state
+        is None, or TaskState in lower case.
+  """
+
+  def __init__(self, name):
+    # Metadata attached before execution.
+    assert isinstance(name, basestring)
+    self.name = name
+    self.properties = {'name': name}
+    self.dimensions = {}
+
+    # Metadata attached during execution.
+    self.task_id = None
+    self.task_server = None
+
+    # Metadata attached after execution.
+    self.task_result = None
+    self.build_proto = None
+    self.outcome = None
+
+  @property
+  def task_url(self):
+    """Returns the URL of the associated task in the Swarming UI."""
+    return "%s/task?id=%s" % (self.task_server, self.task_id)
+
+  @property
+  def milo_url(self):
+    """Returns the URL of the associated task in the Milo UI."""
+    return "https://ci.chromium.org/swarming/task/%s?server=%s" % (
+        self.task_id, self.task_server)
+
+
+class JobApi(recipe_api.RecipeApi):
+  """API for launching jobs and collecting the results."""
+
+  def __init__(self, *args, **kwargs):
+    super(JobApi, self).__init__(*args, **kwargs)
+
+  def new(self, job_name):
+    return Job(job_name)
+
+  def current_recipe(self):
+    return self.m.properties.get('recipe')
+
+  def launch(self, job, presentation):
+    """Launches a job with led.
+
+    Args:
+      job (Job): The job definition.
+      presentation (StepPresentation): The presentation to add logs to.
+
+    Returns:
+      The input job object with additional details about the execution.
+    """
+    current = self.m.buildbucket.build.builder
+    led_data = self.m.led(
+        "get-builder",
+        "luci.%s.%s:%s" % (current.project, current.bucket, current.builder),
+    )
+    edit_args = []
+    for k, v in job.properties.iteritems():
+      edit_args.extend(["-p", "%s=%s" % (k, self.m.json.dumps(v))])
+    for k, v in job.dimensions.iteritems():
+      edit_args.extend(["-d", "%s=%s" % (k, v)])
+    led_data = led_data.then("edit", *edit_args)
+    led_data = self.m.led.inject_input_recipes(led_data)
+    final = led_data.then("launch", "-modernize")
+
+    job.task_id = final.launch_result.task_id
+    job.task_server = final.launch_result.swarming_hostname
+
+    presentation.links[job.name] = job.task_url
+    return job
+
+  def collect(self, jobs, presentation):
+    """Collects execution metadata for a list of jobs.
+
+    Args:
+      jobs (list(Job)): The jobs to collect information for.
+      presentation (StepPresentation): The presentation to add logs to.
+
+    Returns:
+      The input jobs with additional details collected from execution.
+    """
+    by_task_id = {job.task_id: job for job in jobs}
+    swarming_results = self.m.swarming.collect(
+        "collect",
+        by_task_id.keys(),
+        output_dir=self.m.path["cleanup"],
+    )
+    for result in swarming_results:
+      job = by_task_id[result.id]
+      job.task_result = result
+
+      # Led launch ensures this file is present in the task root dir.
+      build_proto_path = result.output_dir.join("build.proto.json")
+      build_proto_json = self.m.file.read_text("read build.proto.json",
+                                               build_proto_path)
+      build_proto = build_pb2.Build()
+      json_format.Parse(build_proto_json, build_proto)
+      job.build_proto = build_proto
+
+      if result.success:
+        job.outcome = "success"
+      elif not result.state:
+        job.outcome = "none"
+      else:
+        # Example result.state: TaskState.COMPLETED
+        job.outcome = str(result.state)[10:].lower()
+
+      presentation.links["%s (%s)" % (job.name, job.outcome)] = job.milo_url
+    return jobs
diff --git a/recipe_modules/job/examples/full.expected/collect.json b/recipe_modules/job/examples/full.expected/collect.json
new file mode 100644
index 0000000..2185d84
--- /dev/null
+++ b/recipe_modules/job/examples/full.expected/collect.json
@@ -0,0 +1,220 @@
+[
+  {
+    "cmd": [],
+    "name": "collect builds",
+    "~followup_annotations": [
+      "@@@STEP_LINK@fake_job0 (success)@https://ci.chromium.org/swarming/task/task_id0?server=None@@@",
+      "@@@STEP_LINK@fake_job1 (success)@https://ci.chromium.org/swarming/task/task_id1?server=None@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "collect builds.install infra/tools/luci/swarming",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin"
+    ],
+    "infra_step": true,
+    "name": "collect builds.install infra/tools/luci/swarming.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin",
+      "-ensure-file",
+      "infra/tools/luci/swarming/${platform} swarming_module_pin",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "collect builds.install infra/tools/luci/swarming.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-swarming_module_\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/swarming/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin/swarming",
+      "collect",
+      "-server",
+      "https://example.swarmingserver.appspot.com",
+      "-task-summary-json",
+      "/path/to/tmp/json",
+      "-task-output-stdout",
+      "json",
+      "-output-dir",
+      "[CLEANUP]",
+      "task_id1",
+      "task_id0"
+    ],
+    "infra_step": true,
+    "name": "collect builds.collect",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"task_id0\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"output\": \"hello world!\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"outputs\": [], @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"results\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"bot_id\": \"vm-123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"duration\": 62.35, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"exit_code\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"name\": \"my_task_0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"outputs_ref\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolated\": \"abc123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolatedserver\": \"https://isolateserver.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"namespace\": \"default-gzip\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"state\": \"COMPLETED\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"task_id\": \"task_id0\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"task_id1\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"output\": \"hello world!\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"outputs\": [], @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"results\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"bot_id\": \"vm-123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"duration\": 62.35, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"exit_code\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"name\": \"my_task_1\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"outputs_ref\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolated\": \"abc123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolatedserver\": \"https://isolateserver.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"namespace\": \"default-gzip\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"state\": \"COMPLETED\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"task_id\": \"task_id1\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@task stdout+stderr: my_task_0@hello world!@@@",
+      "@@@STEP_LOG_END@task stdout+stderr: my_task_0@@@",
+      "@@@STEP_LOG_LINE@task stdout+stderr: my_task_1@hello world!@@@",
+      "@@@STEP_LOG_END@task stdout+stderr: my_task_1@@@",
+      "@@@STEP_LINK@task isolated outputs: my_task_0@https://isolateserver.appspot.com/browse?namespace=default-gzip&hash=abc123@@@",
+      "@@@STEP_LINK@task isolated outputs: my_task_1@https://isolateserver.appspot.com/browse?namespace=default-gzip&hash=abc123@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/task_id0/build.proto.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "collect builds.read build.proto.json",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@{@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"bucket\": \"ci\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createTime\": \"2018-05-25T23:50:17Z\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"priority\": 30@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"resultdb\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"invocation\": \"invocations/build:1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"input\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"host\": \"chromium.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"ref\": \"refs/heads/master\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"id\": \"1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@}@@@",
+      "@@@STEP_LOG_END@build.proto.json@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/task_id1/build.proto.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "collect builds.read build.proto.json (2)",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@{@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"bucket\": \"ci\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createTime\": \"2018-05-25T23:50:17Z\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"priority\": 30@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"resultdb\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"invocation\": \"invocations/build:1001\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"input\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"host\": \"chromium.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"ref\": \"refs/heads/master\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"id\": \"1001\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@}@@@",
+      "@@@STEP_LOG_END@build.proto.json@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/job/examples/full.expected/collect_failed_states.json b/recipe_modules/job/examples/full.expected/collect_failed_states.json
new file mode 100644
index 0000000..5058057
--- /dev/null
+++ b/recipe_modules/job/examples/full.expected/collect_failed_states.json
@@ -0,0 +1,207 @@
+[
+  {
+    "cmd": [],
+    "name": "collect builds",
+    "~followup_annotations": [
+      "@@@STEP_LINK@fake_job0 (none)@https://ci.chromium.org/swarming/task/task_id0?server=None@@@",
+      "@@@STEP_LINK@fake_job1 (timed_out)@https://ci.chromium.org/swarming/task/task_id1?server=None@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "collect builds.install infra/tools/luci/swarming",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin"
+    ],
+    "infra_step": true,
+    "name": "collect builds.install infra/tools/luci/swarming.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin",
+      "-ensure-file",
+      "infra/tools/luci/swarming/${platform} swarming_module_pin",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "collect builds.install infra/tools/luci/swarming.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-swarming_module_\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/swarming/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin/swarming",
+      "collect",
+      "-server",
+      "https://example.swarmingserver.appspot.com",
+      "-task-summary-json",
+      "/path/to/tmp/json",
+      "-task-output-stdout",
+      "json",
+      "-output-dir",
+      "[CLEANUP]",
+      "task_id1",
+      "task_id0"
+    ],
+    "infra_step": true,
+    "name": "collect builds.collect",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"task_id0\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"error\": \"Bot could not be contacted\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"results\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"task_id\": \"task_id0\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"task_id1\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"output\": \"hello world!\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"outputs\": [], @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"results\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"bot_id\": \"vm-123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"duration\": 62.35, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"name\": \"my_task_1\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"outputs_ref\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolated\": \"abc123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolatedserver\": \"https://isolateserver.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"namespace\": \"default-gzip\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"state\": \"TIMED_OUT\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"task_id\": \"task_id1\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@task stdout+stderr: None@Bot could not be contacted@@@",
+      "@@@STEP_LOG_END@task stdout+stderr: None@@@",
+      "@@@STEP_LOG_LINE@task stdout+stderr: my_task_1@hello world!@@@",
+      "@@@STEP_LOG_END@task stdout+stderr: my_task_1@@@",
+      "@@@STEP_LINK@task isolated outputs: my_task_1@https://isolateserver.appspot.com/browse?namespace=default-gzip&hash=abc123@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/task_id0/build.proto.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "collect builds.read build.proto.json",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@{@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"bucket\": \"ci\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createTime\": \"2018-05-25T23:50:17Z\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"priority\": 30@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"resultdb\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"invocation\": \"invocations/build:1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"input\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"host\": \"chromium.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"ref\": \"refs/heads/master\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"id\": \"1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@}@@@",
+      "@@@STEP_LOG_END@build.proto.json@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/task_id1/build.proto.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "collect builds.read build.proto.json (2)",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@{@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"bucket\": \"ci\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createTime\": \"2018-05-25T23:50:17Z\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"priority\": 30@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"resultdb\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"invocation\": \"invocations/build:1001\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"input\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"host\": \"chromium.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"ref\": \"refs/heads/master\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"id\": \"1001\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@}@@@",
+      "@@@STEP_LOG_END@build.proto.json@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/job/examples/full.expected/current_recipe.json b/recipe_modules/job/examples/full.expected/current_recipe.json
new file mode 100644
index 0000000..b6042b6
--- /dev/null
+++ b/recipe_modules/job/examples/full.expected/current_recipe.json
@@ -0,0 +1,5 @@
+[
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/job/examples/full.expected/launch.json b/recipe_modules/job/examples/full.expected/launch.json
new file mode 100644
index 0000000..42dbac1
--- /dev/null
+++ b/recipe_modules/job/examples/full.expected/launch.json
@@ -0,0 +1,141 @@
+[
+  {
+    "cmd": [],
+    "name": "launch job",
+    "~followup_annotations": [
+      "@@@STEP_LINK@fake_job0@chromium-swarm.appspot.com/task?id=job_task_id@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[CACHE]/led",
+      "-ensure-file",
+      "infra/tools/luci/led/${platform} latest",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "name": "launch job.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-latest----------\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/led/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "get-builder",
+      "luci.flutter.try:Linux"
+    ],
+    "name": "launch job.led get-builder",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@proto.output@{@@@",
+      "@@@STEP_LOG_LINE@proto.output@  \"buildbucket\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@    \"bbagent_args\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@      \"build\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"bucket\": \"try\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"builder\": \"Linux\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"project\": \"flutter\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }, @@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"task_id\": \"job_task_id\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@          }@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }@@@",
+      "@@@STEP_LOG_LINE@proto.output@      }@@@",
+      "@@@STEP_LOG_LINE@proto.output@    }@@@",
+      "@@@STEP_LOG_LINE@proto.output@  }@@@",
+      "@@@STEP_LOG_LINE@proto.output@}@@@",
+      "@@@STEP_LOG_END@proto.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "edit",
+      "-p",
+      "foo=[\"a\", \"b\"]",
+      "-p",
+      "recipe=\"fake_recipe\"",
+      "-p",
+      "name=\"fake_job0\"",
+      "-d",
+      "id=fake_bot_id",
+      "-d",
+      "pool=luci.flutter.staging"
+    ],
+    "name": "launch job.led edit",
+    "stdin": "{\n\"buildbucket\": {\n\"bbagent_args\": {\n\"build\": {\n\"builder\": {\n\"bucket\": \"try\", \n\"builder\": \"Linux\", \n\"project\": \"flutter\"\n}, \n\"infra\": {\n\"swarming\": {\n\"task_id\": \"job_task_id\"\n}\n}\n}\n}\n}\n}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@proto.output@{@@@",
+      "@@@STEP_LOG_LINE@proto.output@  \"buildbucket\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@    \"bbagent_args\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@      \"build\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"bucket\": \"try\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"builder\": \"Linux\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"project\": \"flutter\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }, @@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"task_id\": \"job_task_id\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@          }@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }, @@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"input\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"foo\": [@@@",
+      "@@@STEP_LOG_LINE@proto.output@              \"a\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@              \"b\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@            ], @@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"name\": \"fake_job0\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"recipe\": \"fake_recipe\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@          }@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }@@@",
+      "@@@STEP_LOG_LINE@proto.output@      }@@@",
+      "@@@STEP_LOG_LINE@proto.output@    }@@@",
+      "@@@STEP_LOG_LINE@proto.output@  }@@@",
+      "@@@STEP_LOG_LINE@proto.output@}@@@",
+      "@@@STEP_LOG_END@proto.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "launch",
+      "-modernize"
+    ],
+    "name": "launch job.led launch",
+    "stdin": "{\n\"buildbucket\": {\n\"bbagent_args\": {\n\"build\": {\n\"builder\": {\n\"bucket\": \"try\", \n\"builder\": \"Linux\", \n\"project\": \"flutter\"\n}, \n\"infra\": {\n\"swarming\": {\n\"task_id\": \"job_task_id\"\n}\n}, \n\"input\": {\n\"properties\": {\n\"foo\": [\n\"a\", \n\"b\"\n], \n\"name\": \"fake_job0\", \n\"recipe\": \"fake_recipe\"\n}\n}\n}\n}\n}\n}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"host_name\": \"chromium-swarm.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"task_id\": \"job_task_id\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LINK@Swarming task@https://chromium-swarm.appspot.com/task?id=job_task_id@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/job/examples/full.py b/recipe_modules/job/examples/full.py
new file mode 100644
index 0000000..f54cd54
--- /dev/null
+++ b/recipe_modules/job/examples/full.py
@@ -0,0 +1,74 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from recipe_engine.recipe_api import Property
+
+DEPS = [
+    "flutter/job",
+    "recipe_engine/properties",
+    "recipe_engine/step",
+    "recipe_engine/swarming",
+]
+
+PROPERTIES = {
+    "test_name": Property(kind=str, help="Name of the test"),
+}
+
+
+def RunSteps(api, test_name):
+  job0 = api.job.new("fake_job0")
+  job0.properties.update({
+      "recipe": "fake_recipe",
+      "foo": ["a", "b"],
+  })
+  job0.dimensions.update({
+      "id": "fake_bot_id",
+      "pool": "luci.flutter.staging",
+  })
+
+  job1 = api.job.new("fake_job1")
+
+  if test_name == "launch":
+    with api.step.nest("launch job") as presentation:
+      job0 = api.job.launch(job0, presentation)
+  elif test_name == "collect":
+    job0.task_id = "task_id0"
+    job1.task_id = "task_id1"
+    with api.step.nest("collect builds") as presentation:
+      api.job.collect([job0, job1], presentation)
+  elif test_name == "current_recipe":
+    api.job.current_recipe()
+
+
+def GenTests(api):
+  yield api.test(
+      "launch",
+      api.properties(test_name="launch"),
+      api.job.mock_launch(),
+  )
+  yield api.test(
+      "collect",
+      api.properties(test_name="collect"),
+      api.job.mock_collect(["task_id0", "task_id1"], "collect builds"),
+  )
+  yield api.test(
+      "collect_failed_states",
+      api.properties(test_name="collect"),
+      api.job.mock_collect(
+          ["task_id0", "task_id1"],
+          "collect builds",
+          swarming_results=[
+              api.swarming.task_result(
+                  id="task_id0", name="my_task_0", state=None),
+              api.swarming.task_result(
+                  id="task_id1",
+                  name="my_task_1",
+                  state=api.swarming.TaskState.TIMED_OUT),
+          ],
+      ),
+  )
+  yield api.test(
+      "current_recipe",
+      api.properties(test_name="current_recipe"),
+  )
diff --git a/recipe_modules/job/test_api.py b/recipe_modules/job/test_api.py
new file mode 100644
index 0000000..cbb7274
--- /dev/null
+++ b/recipe_modules/job/test_api.py
@@ -0,0 +1,82 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.protobuf import json_format
+from PB.go.chromium.org.luci.led.job import job as job_pb2
+from recipe_engine import recipe_test_api
+
+
+class JobTestApi(recipe_test_api.RecipeTestApi):
+
+  def mock_launch(self, buildbucket_build=None, task_id="job_task_id"):
+    """Returns mock data for the launch function.
+
+    Args:
+      buildbucket_build (TestData): Emulates a buildbucket build. It is usually
+          an output from api.buildbucket.x_build().
+      task_id (str): Id of the swarming task.
+    """
+    ret = self.empty_test_data()
+
+    # Attaches current build.
+    if not buildbucket_build:
+      buildbucket_build = self.m.buildbucket.ci_build(
+          project="flutter", bucket="try", builder="Linux")
+    ret += buildbucket_build
+
+    # Attaches task_id of the launched swarming task.
+    # led launch mock will take ....infra.swarming.task_id as this build's
+    # launched swarming ID.
+    jd = job_pb2.Definition()
+    jd.buildbucket.bbagent_args.build.infra.swarming.task_id = task_id
+    ret += self.m.led.mock_get_builder(jd)
+    return ret
+
+  def mock_collect(self,
+                   task_ids,
+                   presentation_step_name,
+                   swarming_results=None,
+                   build_protos=None):
+    """Returns mock data for the collect function.
+
+    Args:
+      task_ids (list(str)): List of swarming task ids.
+      presentation_step_name (str): The step name of the presentation.
+      swarming_results (list(dict)): List of the outputs from
+          api.swarming.task_result() in the order of task_ids.
+      build_protos (list(build_pb2.Build)): List of build proto messages in the
+          order of task_ids.
+    """
+    ret = self.empty_test_data()
+
+    # Attaches swarming results.
+    if not swarming_results:
+      swarming_results = [
+          self.m.swarming.task_result(id=task_id, name="my_task_%d" % i)
+          for i, task_id in enumerate(task_ids)
+      ]
+
+    ret += self.step_data(
+        "%s.collect" % presentation_step_name,
+        self.m.swarming.collect(swarming_results),
+    )
+
+    # Attaches build protos.
+    if not build_protos:
+      build_protos = [
+          self.m.buildbucket.ci_build_message(build_id=1000 + i)
+          for i, _ in enumerate(task_ids)
+      ]
+
+    for i, id in enumerate(task_ids):
+      # Mocks read build.proto.json.
+      step_name = "%s.read build.proto.json" % presentation_step_name
+      if i > 0:
+        step_name += " (%d)" % (i + 1)
+      ret += self.step_data(
+          step_name,
+          self.m.file.read_text(json_format.MessageToJson(build_protos[i])),
+      )
+
+    return ret
diff --git a/recipes/devicelab.expected/android_defines_test.json b/recipes/devicelab.expected/android_defines_test.json
index fda27ef..b7b7104 100644
--- a/recipes/devicelab.expected/android_defines_test.json
+++ b/recipes/devicelab.expected/android_defines_test.json
@@ -11,7 +11,7 @@
       "--path",
       "[START_DIR]/flutter",
       "--url",
-      "https://abc.com/repo"
+      "https://chromium.googlesource.com/external/github.com/flutter/flutter"
     ],
     "name": "checkout flutter/flutter.git setup",
     "~followup_annotations": [
@@ -23,7 +23,7 @@
       "git",
       "fetch",
       "origin",
-      "refs/pull/123/head",
+      "master",
       "--recurse-submodules",
       "--progress",
       "--tags"
@@ -129,95 +129,6 @@
     "name": "flutter doctor"
   },
   {
-    "cmd": [],
-    "name": "read manifest",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@yaml@@@@",
-      "@@@STEP_LOG_END@yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "/path/to/tmp/"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "infra_step": true,
-    "name": "read manifest.read",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_END@manifest.yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[flutter::yaml]/resources/parse_yaml.py",
-      "--yaml_file",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "--json_file",
-      "/path/to/tmp/json"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "name": "read manifest.parse",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@json.output@{@@@",
-      "@@@STEP_LOG_LINE@json.output@  \"tasks\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@    \"android_defines_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Builds an APK with a --dart-define ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"linux/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flavors_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Checks that flavored builds work on Android.\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flutter_gallery_ios__compile\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Collects various performance metrics of ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab_ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }@@@",
-      "@@@STEP_LOG_LINE@json.output@  }@@@",
-      "@@@STEP_LOG_LINE@json.output@}@@@",
-      "@@@STEP_LOG_END@json.output@@@"
-    ]
-  },
-  {
     "cmd": [
       "pub",
       "get"
diff --git a/recipes/devicelab.expected/flavors_test.json b/recipes/devicelab.expected/flavors_test.json
index 27b8e0c..9906e59 100644
--- a/recipes/devicelab.expected/flavors_test.json
+++ b/recipes/devicelab.expected/flavors_test.json
@@ -11,7 +11,7 @@
       "--path",
       "[START_DIR]/flutter",
       "--url",
-      "https://abc.com/repo"
+      "https://chromium.googlesource.com/external/github.com/flutter/flutter"
     ],
     "name": "checkout flutter/flutter.git setup",
     "~followup_annotations": [
@@ -23,7 +23,7 @@
       "git",
       "fetch",
       "origin",
-      "refs/pull/123/head",
+      "master",
       "--recurse-submodules",
       "--progress",
       "--tags"
@@ -129,95 +129,6 @@
     "name": "flutter doctor"
   },
   {
-    "cmd": [],
-    "name": "read manifest",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@yaml@@@@",
-      "@@@STEP_LOG_END@yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "/path/to/tmp/"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "infra_step": true,
-    "name": "read manifest.read",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_END@manifest.yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[flutter::yaml]/resources/parse_yaml.py",
-      "--yaml_file",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "--json_file",
-      "/path/to/tmp/json"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "name": "read manifest.parse",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@json.output@{@@@",
-      "@@@STEP_LOG_LINE@json.output@  \"tasks\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@    \"android_defines_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Builds an APK with a --dart-define ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"linux/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flavors_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Checks that flavored builds work on Android.\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flutter_gallery_ios__compile\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Collects various performance metrics of ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab_ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }@@@",
-      "@@@STEP_LOG_LINE@json.output@  }@@@",
-      "@@@STEP_LOG_LINE@json.output@}@@@",
-      "@@@STEP_LOG_END@json.output@@@"
-    ]
-  },
-  {
     "cmd": [
       "pub",
       "get"
diff --git a/recipes/devicelab.expected/flutter_gallery_ios__compile.json b/recipes/devicelab.expected/flutter_gallery_ios__compile.json
index a074225..0dd2942 100644
--- a/recipes/devicelab.expected/flutter_gallery_ios__compile.json
+++ b/recipes/devicelab.expected/flutter_gallery_ios__compile.json
@@ -11,7 +11,7 @@
       "--path",
       "[START_DIR]/flutter",
       "--url",
-      "https://abc.com/repo"
+      "https://chromium.googlesource.com/external/github.com/flutter/flutter"
     ],
     "name": "checkout flutter/flutter.git setup",
     "~followup_annotations": [
@@ -23,7 +23,7 @@
       "git",
       "fetch",
       "origin",
-      "refs/pull/123/head",
+      "master",
       "--recurse-submodules",
       "--progress",
       "--tags"
@@ -129,95 +129,6 @@
     "name": "flutter doctor"
   },
   {
-    "cmd": [],
-    "name": "read manifest",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@yaml@@@@",
-      "@@@STEP_LOG_END@yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "/path/to/tmp/"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "infra_step": true,
-    "name": "read manifest.read",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_END@manifest.yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[flutter::yaml]/resources/parse_yaml.py",
-      "--yaml_file",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "--json_file",
-      "/path/to/tmp/json"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "name": "read manifest.parse",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@json.output@{@@@",
-      "@@@STEP_LOG_LINE@json.output@  \"tasks\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@    \"android_defines_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Builds an APK with a --dart-define ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"linux/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flavors_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Checks that flavored builds work on Android.\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flutter_gallery_ios__compile\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Collects various performance metrics of ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab_ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }@@@",
-      "@@@STEP_LOG_LINE@json.output@  }@@@",
-      "@@@STEP_LOG_LINE@json.output@}@@@",
-      "@@@STEP_LOG_END@json.output@@@"
-    ]
-  },
-  {
     "cmd": [
       "pub",
       "get"
diff --git a/recipes/devicelab.expected/schedule.json b/recipes/devicelab.expected/schedule.json
new file mode 100644
index 0000000..6be6f8e
--- /dev/null
+++ b/recipes/devicelab.expected/schedule.json
@@ -0,0 +1,467 @@
+[
+  {
+    "cmd": [],
+    "name": "checkout flutter/flutter"
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[START_DIR]/flutter",
+      "--url",
+      "https://chromium.googlesource.com/external/github.com/flutter/flutter"
+    ],
+    "name": "checkout flutter/flutter.git setup",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "master",
+      "--recurse-submodules",
+      "--progress",
+      "--tags"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "checkout flutter/flutter.git fetch",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "checkout flutter/flutter.git checkout",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "checkout flutter/flutter.read revision",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@",
+      "@@@SET_BUILD_PROPERTY@got_revision@\"deadbeef\"@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "checkout flutter/flutter.git clean",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "sync"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "checkout flutter/flutter.submodule sync",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--recursive"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "checkout flutter/flutter.submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "read manifest",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@yaml@@@@",
+      "@@@STEP_LOG_END@yaml@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/flutter/dev/devicelab",
+    "env": {
+      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
+      "PUB_CACHE": "[CACHE]/.pub-cache"
+    },
+    "env_prefixes": {
+      "PATH": [
+        "[START_DIR]/flutter/bin",
+        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
+      ]
+    },
+    "infra_step": true,
+    "name": "read manifest.read",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_END@manifest.yaml@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[flutter::yaml]/resources/parse_yaml.py",
+      "--yaml_file",
+      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
+      "--json_file",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/flutter/dev/devicelab",
+    "env": {
+      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
+      "PUB_CACHE": "[CACHE]/.pub-cache"
+    },
+    "env_prefixes": {
+      "PATH": [
+        "[START_DIR]/flutter/bin",
+        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
+      ]
+    },
+    "name": "read manifest.parse",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tasks\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"android_defines_test\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Builds an APK with a --dart-define ...\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"on_luci\": true, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"linux/android\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      ], @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "launch jobs",
+    "~followup_annotations": [
+      "@@@STEP_LINK@android_defines_test@chromium-swarm.appspot.com/task?id=fake-task-id@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[CACHE]/led",
+      "-ensure-file",
+      "infra/tools/luci/led/${platform} latest",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "name": "launch jobs.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-latest----------\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/led/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "get-builder",
+      "luci..:"
+    ],
+    "name": "launch jobs.led get-builder",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@proto.output@{@@@",
+      "@@@STEP_LOG_LINE@proto.output@  \"buildbucket\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@    \"bbagent_args\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@      \"build\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"builder\": {}@@@",
+      "@@@STEP_LOG_LINE@proto.output@      }@@@",
+      "@@@STEP_LOG_LINE@proto.output@    }@@@",
+      "@@@STEP_LOG_LINE@proto.output@  }@@@",
+      "@@@STEP_LOG_LINE@proto.output@}@@@",
+      "@@@STEP_LOG_END@proto.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "edit",
+      "-p",
+      "recipe=\"devicelab\"",
+      "-p",
+      "role=\"worker\"",
+      "-p",
+      "name=\"android_defines_test\"",
+      "-p",
+      "first_capability=\"linux/android\"",
+      "-d",
+      "cores=",
+      "-d",
+      "caches=",
+      "-d",
+      "os=",
+      "-d",
+      "cpu=",
+      "-d",
+      "pool=luci.flutter.staging",
+      "-d",
+      "id=flutter-devicelab-linux-8"
+    ],
+    "name": "launch jobs.led edit",
+    "stdin": "{\n\"buildbucket\": {\n\"bbagent_args\": {\n\"build\": {\n\"builder\": {}\n}\n}\n}\n}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@proto.output@{@@@",
+      "@@@STEP_LOG_LINE@proto.output@  \"buildbucket\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@    \"bbagent_args\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@      \"build\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"builder\": {}, @@@",
+      "@@@STEP_LOG_LINE@proto.output@        \"input\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@          \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"first_capability\": \"linux/android\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"name\": \"android_defines_test\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"recipe\": \"devicelab\", @@@",
+      "@@@STEP_LOG_LINE@proto.output@            \"role\": \"worker\"@@@",
+      "@@@STEP_LOG_LINE@proto.output@          }@@@",
+      "@@@STEP_LOG_LINE@proto.output@        }@@@",
+      "@@@STEP_LOG_LINE@proto.output@      }@@@",
+      "@@@STEP_LOG_LINE@proto.output@    }@@@",
+      "@@@STEP_LOG_LINE@proto.output@  }@@@",
+      "@@@STEP_LOG_LINE@proto.output@}@@@",
+      "@@@STEP_LOG_END@proto.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/led/led",
+      "launch",
+      "-modernize"
+    ],
+    "name": "launch jobs.led launch",
+    "stdin": "{\n\"buildbucket\": {\n\"bbagent_args\": {\n\"build\": {\n\"builder\": {}, \n\"input\": {\n\"properties\": {\n\"first_capability\": \"linux/android\", \n\"name\": \"android_defines_test\", \n\"recipe\": \"devicelab\", \n\"role\": \"worker\"\n}\n}\n}\n}\n}\n}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"host_name\": \"chromium-swarm.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"task_id\": \"fake-task-id\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LINK@Swarming task@https://chromium-swarm.appspot.com/task?id=fake-task-id@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "collect jobs",
+    "~followup_annotations": [
+      "@@@STEP_LINK@android_defines_test (success)@https://ci.chromium.org/swarming/task/fake-task-id?server=chromium-swarm.appspot.com@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "collect jobs.install infra/tools/luci/swarming",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin"
+    ],
+    "infra_step": true,
+    "name": "collect jobs.install infra/tools/luci/swarming.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin",
+      "-ensure-file",
+      "infra/tools/luci/swarming/${platform} swarming_module_pin",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "collect jobs.install infra/tools/luci/swarming.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-swarming_module_\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/swarming/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[CACHE]/cipd/infra/tools/luci/swarming/swarming_module_pin/swarming",
+      "collect",
+      "-server",
+      "https://example.swarmingserver.appspot.com",
+      "-task-summary-json",
+      "/path/to/tmp/json",
+      "-task-output-stdout",
+      "json",
+      "-output-dir",
+      "[CLEANUP]",
+      "fake-task-id"
+    ],
+    "infra_step": true,
+    "name": "collect jobs.collect",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"fake-task-id\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"output\": \"hello world!\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"outputs\": [], @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"results\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"bot_id\": \"vm-123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"duration\": 62.35, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"exit_code\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"name\": \"my_task_0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"outputs_ref\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolated\": \"abc123\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"isolatedserver\": \"https://isolateserver.appspot.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"namespace\": \"default-gzip\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"state\": \"COMPLETED\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"task_id\": \"fake-task-id\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@task stdout+stderr: my_task_0@hello world!@@@",
+      "@@@STEP_LOG_END@task stdout+stderr: my_task_0@@@",
+      "@@@STEP_LINK@task isolated outputs: my_task_0@https://isolateserver.appspot.com/browse?namespace=default-gzip&hash=abc123@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/fake-task-id/build.proto.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "collect jobs.read build.proto.json",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@{@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"bucket\": \"ci\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createTime\": \"2018-05-25T23:50:17Z\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"infra\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"swarming\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"priority\": 30@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"resultdb\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"invocation\": \"invocations/build:1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"input\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"project\": \"project\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"host\": \"chromium.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"ref\": \"refs/heads/master\", @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@      \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@    }@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  }, @@@",
+      "@@@STEP_LOG_LINE@build.proto.json@  \"id\": \"1000\"@@@",
+      "@@@STEP_LOG_LINE@build.proto.json@}@@@",
+      "@@@STEP_LOG_END@build.proto.json@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/devicelab.expected/missing_task_name.json b/recipes/devicelab.expected/unknown_role.json
similarity index 63%
rename from recipes/devicelab.expected/missing_task_name.json
rename to recipes/devicelab.expected/unknown_role.json
index ffdbff8..eb5df5b 100644
--- a/recipes/devicelab.expected/missing_task_name.json
+++ b/recipes/devicelab.expected/unknown_role.json
@@ -14,15 +14,15 @@
       "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/property_invoker.py\", in invoke_with_properties",
       "    arg_names, **additional_args)",
       "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/property_invoker.py\", in _invoke_with_properties",
-      "    prop.interpret(all_props.get(prop_name, PROPERTY_SENTINEL), environ))",
-      "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/recipe_api.py\", in interpret",
-      "    self.name, self.__property_type, self.full_decl_name))",
-      "ValueError: No default specified and no value provided for 'task_name' from recipe 'flutter::devicelab'"
+      "    return callable_obj(*props, **additional_args)",
+      "  File \"RECIPE_REPO[flutter]/recipes/devicelab.py\", line 32, in RunSteps",
+      "    raise ValueError('Unknown role: %s' % role)",
+      "ValueError: Unknown role: unknown"
     ]
   },
   {
     "failure": {
-      "humanReason": "Uncaught Exception: ValueError(\"No default specified and no value provided for 'task_name' from recipe 'flutter::devicelab'\",)"
+      "humanReason": "Uncaught Exception: ValueError('Unknown role: unknown',)"
     },
     "name": "$result"
   }
diff --git a/recipes/devicelab.expected/unknown_task.json b/recipes/devicelab.expected/unknown_task.json
deleted file mode 100644
index 986ad51..0000000
--- a/recipes/devicelab.expected/unknown_task.json
+++ /dev/null
@@ -1,247 +0,0 @@
-[
-  {
-    "cmd": [],
-    "name": "checkout flutter/flutter"
-  },
-  {
-    "cmd": [
-      "python",
-      "-u",
-      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
-      "--path",
-      "[START_DIR]/flutter",
-      "--url",
-      "https://chromium.googlesource.com/external/github.com/flutter/flutter"
-    ],
-    "name": "checkout flutter/flutter.git setup",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "fetch",
-      "origin",
-      "master",
-      "--recurse-submodules",
-      "--progress",
-      "--tags"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "env": {
-      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
-    },
-    "infra_step": true,
-    "name": "checkout flutter/flutter.git fetch",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "checkout",
-      "-f",
-      "FETCH_HEAD"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "infra_step": true,
-    "name": "checkout flutter/flutter.git checkout",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "rev-parse",
-      "HEAD"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "infra_step": true,
-    "name": "checkout flutter/flutter.read revision",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@",
-      "@@@SET_BUILD_PROPERTY@got_revision@\"deadbeef\"@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "clean",
-      "-f",
-      "-d",
-      "-x"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "infra_step": true,
-    "name": "checkout flutter/flutter.git clean",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "sync"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "infra_step": true,
-    "name": "checkout flutter/flutter.submodule sync",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "update",
-      "--init",
-      "--recursive"
-    ],
-    "cwd": "[START_DIR]/flutter",
-    "infra_step": true,
-    "name": "checkout flutter/flutter.submodule update",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "flutter",
-      "doctor"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "name": "flutter doctor"
-  },
-  {
-    "cmd": [],
-    "name": "read manifest",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@yaml@@@@",
-      "@@@STEP_LOG_END@yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "/path/to/tmp/"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "infra_step": true,
-    "name": "read manifest.read",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_END@manifest.yaml@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[flutter::yaml]/resources/parse_yaml.py",
-      "--yaml_file",
-      "[START_DIR]/flutter/dev/devicelab/manifest.yaml",
-      "--json_file",
-      "/path/to/tmp/json"
-    ],
-    "cwd": "[START_DIR]/flutter/dev/devicelab",
-    "env": {
-      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
-      "PUB_CACHE": "[CACHE]/.pub-cache"
-    },
-    "env_prefixes": {
-      "PATH": [
-        "[START_DIR]/flutter/bin",
-        "[START_DIR]/flutter/bin/cache/dart-sdk/bin"
-      ]
-    },
-    "name": "read manifest.parse",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@json.output@{@@@",
-      "@@@STEP_LOG_LINE@json.output@  \"tasks\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@    \"android_defines_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Builds an APK with a --dart-define ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"linux/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flavors_test\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Checks that flavored builds work on Android.\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/android\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }, @@@",
-      "@@@STEP_LOG_LINE@json.output@    \"flutter_gallery_ios__compile\": {@@@",
-      "@@@STEP_LOG_LINE@json.output@      \"description\": \"Collects various performance metrics of ...\", @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"required_agent_capabilities\": [@@@",
-      "@@@STEP_LOG_LINE@json.output@        \"mac/ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@      ], @@@",
-      "@@@STEP_LOG_LINE@json.output@      \"stage\": \"devicelab_ios\"@@@",
-      "@@@STEP_LOG_LINE@json.output@    }@@@",
-      "@@@STEP_LOG_LINE@json.output@  }@@@",
-      "@@@STEP_LOG_LINE@json.output@}@@@",
-      "@@@STEP_LOG_END@json.output@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "RECIPE CRASH (Uncaught exception)",
-    "~followup_annotations": [
-      "@@@STEP_EXCEPTION@@@",
-      "The recipe has crashed at point 'Uncaught exception'!",
-      "",
-      "Traceback (most recent call last):",
-      "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/engine.py\", in run_steps",
-      "    raw_result = recipe_obj.run_steps(api, engine)",
-      "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/recipe_deps.py\", in run_steps",
-      "    properties_def, api=api)",
-      "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/property_invoker.py\", in invoke_with_properties",
-      "    arg_names, **additional_args)",
-      "  File \"RECIPE_REPO[recipe_engine]/recipe_engine/internal/property_invoker.py\", in _invoke_with_properties",
-      "    return callable_obj(*props, **additional_args)",
-      "  File \"RECIPE_REPO[flutter]/recipes/devicelab.py\", line 52, in RunSteps",
-      "    raise ValueError('Unknown task: %s' % task_name)",
-      "ValueError: Unknown task: unknown_task"
-    ]
-  },
-  {
-    "failure": {
-      "humanReason": "Uncaught Exception: ValueError('Unknown task: unknown_task',)"
-    },
-    "name": "$result"
-  }
-]
\ No newline at end of file
diff --git a/recipes/devicelab.py b/recipes/devicelab.py
index 9a5ad65..6f6e225 100644
--- a/recipes/devicelab.py
+++ b/recipes/devicelab.py
@@ -6,27 +6,33 @@
 
 DEPS = [
     'depot_tools/git',
-    'depot_tools/osx_sdk',
     'flutter/android_sdk',
     'flutter/repo_util',
     'flutter/yaml',
-    'recipe_engine/cipd',
     'recipe_engine/context',
     'recipe_engine/file',
     'recipe_engine/json',
     'recipe_engine/path',
-    'recipe_engine/platform',
     'recipe_engine/properties',
-    'recipe_engine/raw_io',
     'recipe_engine/step',
+    "flutter/job",
 ]
 
 PROPERTIES = {
-    'task_name': Property(kind=str, help='Name of the devicelab task to run'),
+    'role': Property(kind=str, help='either scheduler or worker'),
 }
 
 
-def RunSteps(api, task_name):
+def RunSteps(api, role):
+  if role == "scheduler":
+    schedule_all(api)
+  elif role == 'worker':
+    run_task(api)
+  else:
+    raise ValueError('Unknown role: %s' % role)
+
+
+def schedule_all(api):
   flutter_path = api.path['start_dir'].join('flutter')
   with api.step.nest('checkout flutter/flutter'):
     api.repo_util.checkout(
@@ -35,28 +41,78 @@
         api.properties.get('git_url'),
         api.properties.get('git_ref'),
     )
+
   env, env_prefixes = api.repo_util.flutter_environment(flutter_path)
   devicelab_path = flutter_path.join('dev', 'devicelab')
-  with api.context(env=env, env_prefixes=env_prefixes, cwd=devicelab_path):
-    api.step('flutter doctor', ['flutter', 'doctor'])
 
+  sub_jobs = []
+  with api.context(env=env, env_prefixes=env_prefixes, cwd=devicelab_path):
     # Reads the manifest.
     result = api.yaml.read('read manifest',
                            devicelab_path.join('manifest.yaml'),
                            api.json.output())
     manifest = result.json.output
+    for task_name, task_body in manifest['tasks'].iteritems():
+      # Example first capability values: linux/android, mac/ios.
+      first_capability = task_body['required_agent_capabilities'][0]
+      if task_body.get('on_luci'):
+        sub_job = api.job.new(task_name)
+        sub_job.properties.update({
+            "recipe": api.job.current_recipe(),
+            "role": "worker",
+            "first_capability": first_capability,
+        })
+        # TODO(wutong): add a devicelab dedicated builder that would save us
+        # from removing extra dimensions like "cores", "os" etc.
+        sub_job.dimensions.update({
+            "id": select_bot(first_capability),
+            "pool": "luci.flutter.staging",
+            "cores": "",
+            "os": "",
+            "cpu": "",
+            "caches": "",
+        })
+        sub_jobs.append(sub_job)
 
-    # Verifies that the manifest contains the task to run.
-    task = manifest['tasks'].get(task_name)
-    if not task:
-      raise ValueError('Unknown task: %s' % task_name)
+  with api.step.nest("launch jobs") as presentation:
+    for sub_job in sub_jobs:
+      api.job.launch(sub_job, presentation)
 
-    # Example first capability values: linux/android, mac/ios.
-    first_capability = task['required_agent_capabilities'][0]
-    sdk = first_capability.split('/')[1]
+  with api.step.nest("collect jobs") as presentation:
+    api.job.collect(sub_jobs, presentation)
+
+
+def select_bot(first_capability):
+  # TODO(wutong): apply bot selection by dimensions instead of hard-coded ids.
+  mapping = {
+      "linux/android": "flutter-devicelab-linux-8",
+      "mac/android": "flutter-devicelab-mac-22",
+      "mac/ios": "flutter-devicelab-mac-9",
+  }
+  return mapping.get(first_capability)
+
+
+def run_task(api):
+  task_name = api.properties["name"]
+  first_capability = api.properties["first_capability"]
+
+  flutter_path = api.path['start_dir'].join('flutter')
+  with api.step.nest('checkout flutter/flutter'):
+    api.repo_util.checkout(
+        'flutter',
+        flutter_path,
+        api.properties.get('git_url'),
+        api.properties.get('git_ref'),
+    )
+
+  env, env_prefixes = api.repo_util.flutter_environment(flutter_path)
+  devicelab_path = flutter_path.join('dev', 'devicelab')
+  with api.context(env=env, env_prefixes=env_prefixes, cwd=devicelab_path):
+    api.step('flutter doctor', ['flutter', 'doctor'])
+    api.step('pub get', ['pub', 'get'])
 
     # Runs a task.
-    api.step('pub get', ['pub', 'get'])
+    sdk = first_capability.split('/')[1]
     if sdk == 'android':
       run_android_task(api, task_name)
     elif sdk == 'ios':
@@ -83,8 +139,16 @@
 
 
 def GenTests(api):
+  for t in gen_scheduler_tests(api):
+    yield t
+  for t in gen_worker_tests(api):
+    yield t
+
+
+def gen_scheduler_tests(api):
   yield api.test(
-      'missing_task_name',
+      "unknown_role",
+      api.properties(role="unknown"),
       api.expect_exception('ValueError'),
   )
 
@@ -94,40 +158,50 @@
               "description": "Builds an APK with a --dart-define ...",
               "stage": "devicelab",
               "required_agent_capabilities": ["linux/android"],
+              "on_luci": True,
+          },
+      },
+  }
+  yield api.test(
+      "schedule", api.properties(role="scheduler"),
+      api.repo_util.flutter_environment_data(),
+      api.step_data('read manifest.parse', api.json.output(sample_manifest)),
+      api.job.mock_collect(["fake-task-id"], "collect jobs"))
+
+
+def gen_worker_tests(api):
+  sample_manifest = {
+      "tasks": {
+          "android_defines_test": {
+              "description": "Builds an APK with a --dart-define ...",
+              "stage": "devicelab",
+              "required_agent_capabilities": ["linux/android"],
+              "on_luci": True,
           },
           "flavors_test": {
               "description": "Checks that flavored builds work on Android.",
               "stage": "devicelab",
               "required_agent_capabilities": ["mac/android"],
+              "on_luci": True,
           },
           "flutter_gallery_ios__compile": {
               "description": "Collects various performance metrics of ...",
               "stage": "devicelab_ios",
               "required_agent_capabilities": ["mac/ios"],
+              "on_luci": True,
           },
       },
   }
-
-  yield api.test(
-      'unknown_task',
-      api.properties(task_name='unknown_task'),
-      api.repo_util.flutter_environment_data(),
-      api.step_data('read manifest.parse', api.json.output(sample_manifest)),
-      api.expect_exception('ValueError'),
-  )
-
-  for task_name in [
-      'android_defines_test', 'flavors_test', 'flutter_gallery_ios__compile'
-  ]:
+  for task_name in sample_manifest["tasks"].keys():
     yield api.test(
         task_name,
         api.properties(
-            git_ref='refs/pull/123/head', git_url='https://abc.com/repo'),
-        api.properties(
-            task_name=task_name,
+            role="worker",
+            name=task_name,
+            first_capability=(sample_manifest["tasks"][task_name]
+                              ["required_agent_capabilities"][0]),
             android_sdk_license='android_sdk_hash',
             android_sdk_preview_license='android_sdk_preview_hash',
         ),
         api.repo_util.flutter_environment_data(),
-        api.step_data('read manifest.parse', api.json.output(sample_manifest)),
     )