Repository: StackStorm/st2

Issue:
Reverse workflow failed in Stackstorm
Hi there,

I've created a reverse workflow in stackstorm and get error as below:

  "error": "Invalid task name [wf_spec=ReverseWorkflowSpec {'input': ['test1', 'test2'], 'tasks': {'testa': {'action': 'st2.action', 'input': {'ref': 'core.noop'}, 'version': '2.0', 'type': 'reverse', 'name': 'testa'}}, 'type': 'reverse', 'name': 'dev.test_reverse', 'version': '2.0'}, task_name=None]"

So stackstorm doesn't support reverse flow, right?


Discussion:
The [documentation](https://docs.stackstorm.com/mistral.html) indicates that reverse workflows are supported. What is your action metadata and your workflow definition?
@LindsayHill Thanks for your response.
The metadata:

```
---
name: "test_reverse"
runner_type: "mistral-v2"
pack: "dev"
enabled: true
entry_point: "workflows/test_reverse.yaml"

parameters:
  test1:
    required: false
    type: string
    position: 0
  test2:
    required: false
    type: string
    position: 1
```


The definition:

```
version: '2.0'

dev.test_reverse:
  type: reverse
  input:
    - test1
    - test2

  tasks:
    step1:
      action: core.noop
    step2:
      action: core.noop
    step3:
      action: core.noop
      requires: [step2]
    step4:
      action: core.noop
      requires: [step1,step2]
```

And the error:

```
{
  "traceback": "  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/st2actions/container/base.py\", line 120, in _do_run\n    (status, result, context) = runner.run(action_params)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 49, in wrapped_f\n    return Retrying(*dargs, **dkw).call(f, *args, **kw)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 206, in call\n    return attempt.get(self._wrap_exception)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 247, in get\n    six.reraise(self.value[0], self.value[1], self.value[2])\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 200, in call\n    attempt = Attempt(fn(*args, **kwargs), attempt_number, False)\n  File \"/opt/stackstorm/runners/mistral_v2/mistral_v2/mistral_v2.py\", line 239, in run\n    result = self.start_workflow(action_parameters=action_parameters)\n  File \"/opt/stackstorm/runners/mistral_v2/mistral_v2/mistral_v2.py\", line 281, in start_workflow\n    **options)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/v2/executions.py\", line 59, in create\n    return self._create('/executions', data)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/base.py\", line 100, in _create\n    self._raise_api_exception(resp)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/base.py\", line 160, in _raise_api_exception\n    error_message=error_data)\n",
  "error": "Invalid task name [wf_spec=ReverseWorkflowSpec {u'input': [u'test1', u'test2'], u'tasks': {u'step4': {u'name': u'step4', u'requires': [u'step1', u'step2'], u'version': '2.0', u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'type': u'reverse'}, u'step3': {u'name': u'step3', u'requires': [u'step2'], u'version': '2.0', u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'type': u'reverse'}, u'step2': {u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'version': '2.0', u'type': u'reverse', u'name': u'step2'}, u'step1': {u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'version': '2.0', u'type': u'reverse', u'name': u'step1'}}, u'type': u'reverse', u'name': u'dev.test_reverse', u'version': u'2.0'}, task_name=None]"
}
```

I think that you need to define a target task in the metadata. Something like this:
```yaml
  task:
    required: true
    type: string
```

In your case that might be something like this:

```yaml
  task:
    default: step4    
    immutable: true
    type: string
```    
@LindsayHill I've simplified the flow as your suggestion:

meta:

```
---
name: "test_reverse"
runner_type: "mistral-v2"
pack: "dev"
enabled: true
entry_point: "workflows/test_reverse.yaml"

parameters:
  test1:
    required: false
    type: string
    position: 0
  test2:
    required: false
    type: string
    position: 1
  task:
    default: step4
    immutable: true
    type: string
```

definition
```
version: '2.0'

dev.test_reverse:
  type: reverse
  input:
    - test1
    - test2

  tasks:
    step1:
      action: core.noop
    step4:
      action: core.noop
      requires: [step1]:
```

And still get errors:

```
{
  "traceback": "  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/st2actions/container/base.py\", line 120, in _do_run\n    (status, result, context) = runner.run(action_params)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 49, in wrapped_f\n    return Retrying(*dargs, **dkw).call(f, *args, **kw)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 206, in call\n    return attempt.get(self._wrap_exception)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 247, in get\n    six.reraise(self.value[0], self.value[1], self.value[2])\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/retrying.py\", line 200, in call\n    attempt = Attempt(fn(*args, **kwargs), attempt_number, False)\n  File \"/opt/stackstorm/runners/mistral_v2/mistral_v2/mistral_v2.py\", line 239, in run\n    result = self.start_workflow(action_parameters=action_parameters)\n  File \"/opt/stackstorm/runners/mistral_v2/mistral_v2/mistral_v2.py\", line 281, in start_workflow\n    **options)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/v2/executions.py\", line 59, in create\n    return self._create('/executions', data)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/base.py\", line 100, in _create\n    self._raise_api_exception(resp)\n  File \"/opt/stackstorm/st2/lib/python2.7/site-packages/mistralclient/api/base.py\", line 160, in _raise_api_exception\n    error_message=error_data)\n",
  "error": "Invalid task name [wf_spec=ReverseWorkflowSpec {u'input': [u'test1', u'test2'], u'tasks': {u'step4': {u'name': u'step4', u'requires': [u'step1'], u'version': '2.0', u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'type': u'reverse'}, u'step1': {u'action': u'st2.action', u'input': {u'ref': u'core.noop'}, u'version': '2.0', u'type': u'reverse', u'name': u'step1'}}, u'type': u'reverse', u'name': u'dev.test_reverse', u'version': u'2.0'}, task_name=None]"
}
```
I'm looking at using `reverse` workflows. Before starting, I asked in slack if there are any issues with `reverse` workflows, and @LindsayHill pointed me here.

I haven't tested yet, but I think the target task is defined in parameter `task_name` not `task`. So, try:
```yaml
---
name: "test_reverse"
runner_type: "mistral-v2"
pack: "dev"
enabled: true
entry_point: "workflows/test_reverse.yaml"

parameters:
  test1:
    required: false
    type: string
    position: 0
  test2:
    required: false
    type: string
    position: 1
  task_name:
    default: step4
    immutable: true
    type: string
```

=================================================================================

Background on why I think `task_name` should work where `task` does not (only based on reading code, I still need to test):

It looks like the [mistral runner](https://github.com/StackStorm/st2/blob/master/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py) doesn't explicitly use the `task` parameter anywhere (nor the `task_name` parameter). So, the parameter gets passed through to mistral 

https://github.com/StackStorm/st2/blob/69ecdb8a30db35027d72abef51d08cd371c3ee82/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py#L248-L250

https://github.com/StackStorm/st2/blob/69ecdb8a30db35027d72abef51d08cd371c3ee82/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py#L275-L282

So, how does mistral expect the target task to be defined? The docs seem to be missing this tidbit of info. But, googling a bit, I found this snippet of code in mistral:

```python
    def _get_target_task_specification(self):
        task_name = self.wf_ex.params.get('task_name')

        task_spec = self.wf_spec.get_tasks().get(task_name)
```
see [mistral/workflow/reverse_workflow.py#L72-L75](https://github.com/openstack/mistral/blob/6c25d6de410a91701fe8e1cc18086b01df53b9b3/mistral/workflow/reverse_workflow.py#L72-L75)

Plus, there's a mistral issue where someone had a similar problem, but it was closed as invalid, and a comment explains how to provide the target task:
> User needs to identify the final task, task that will be completed last, to have an ability to build a graph of dependencies and calculate the task execution order.
>
> Try the following:
> `mistral execution-create id {} task_name.json`
>
> where `task_name.json` is:
>
> ```json
> {
>     "task_name": "echo_after"
> }
> ```
Qutoing @akuznetsova From [issue 1679578](https://bugs.launchpad.net/mistral/+bug/1679578) [comment 1](https://bugs.launchpad.net/mistral/+bug/1679578/comments/1) (formatting is mine)
@m4dcoder asked in slack for my use-case (why I would rather use a `reverse` over a `direct` workflow). I'll copy what I said in slack, expounding on a couple of the points to make my use case clearer.

I have a workflow with a set of tasks that process or retrieve various bits of data. I want them to run as fast as possible in parallel, and I don't really care about the order, except that some tasks require the data from other tasks to look up or figure out their bit of data. In the end, there are three tasks that use that data to configure a server (with an ansible playbook that gets input data via extra_args) and a couple of other servers that need to know about that server (using core.remote in one case, and I haven't decided whether to use a playbook or something else for manipulating the sqlite database on the third server).

Right now, I have an action-chain workflow with a bunch of on-successes. But, as I convert that to a mistral workflow I'd rather not have to manually define all of `on-success` things, and figure out where I need to add some `core.noop` tasks to serve as synchronization points, collecting data before continuing with dependent tasks. I don't want to figure out these points of synchronization and where things can go in parallel. Just saying which data is required to run that task is enough. With a direct workflow, I only need to add one `core.noop` task that requires on my three target tasks, and then let the workflow engine figure out the ordering of everything else.

With the action-chain, I kept bouncing the order of the on-successes around as I added or removed tasks. It's annoying to have to change on-success requirements on unrelated tasks (the only relation being they come earlier). The required data for each task is much easier to define (in this case) than defining the order of the tasks. As I add additional setup tasks going forward (telling more parts of our infrastructure about this new server), I want to avoid constantly mangling the chain of on-successes that has little to do with what I'm trying to accomplish. Thus, a `reverse` workflow looks like a much simpler for a complex data manipulation workflow that will only expand going forward.
Oh, and one more thing to note:
There are no `reverse` workflows in the ST2 tests/examples:
https://github.com/StackStorm/st2/tree/master/contrib/examples/actions/workflows/tests
https://github.com/StackStorm/st2/tree/master/contrib/examples/actions/workflows
https://github.com/StackStorm/st2/tree/master/contrib/examples/actions
Looks like there is a bug in our mistral runner. Mistral expects a target `task_name` to be given for reverse workflow (the final task in the WF waiting on the requires). Our mistral runner is passing `task` instead.

Resolving patch:
diff --git a/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py b/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py
--- a/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py
+++ b/contrib/runners/mistral_v2/mistral_v2/mistral_v2.py
@@ -219,6 +219,11 @@ def _construct_workflow_execution_options(self):
         if not self.is_polling_enabled():
             options['notify'] = [{'type': 'st2'}]
 
+        # Only used on reverse type workflows
+        task_name = self.runner_parameters.get('task_name', None)
+        if task_name is not None:
+            options['task_name'] = task_name
+
         return options
 
     def _get_resume_options(self):


Test patch:
diff --git a/contrib/runners/mistral_v2/tests/unit/test_mistral_v2.py b/contrib/runners/mistral_v2/tests/unit/test_mistral_v2.py
--- a/contrib/runners/mistral_v2/tests/unit/test_mistral_v2.py
+++ b/contrib/runners/mistral_v2/tests/unit/test_mistral_v2.py
@@ -59,7 +59,8 @@
         'workbook_v2_many_workflows.yaml',
         'workbook_v2_many_workflows_no_default.yaml',
         'workflow_v2.yaml',
-        'workflow_v2_many_workflows.yaml'
+        'workflow_v2_many_workflows.yaml',
+        'workflow_v2_reverse.yaml',
     ],
     'actions': [
         'workbook_v2.yaml',
@@ -68,7 +69,8 @@
         'workflow_v2.yaml',
         'workflow_v2_many_workflows.yaml',
         'workbook_v2_name_mismatch.yaml',
-        'workflow_v2_name_mismatch.yaml'
+        'workflow_v2_name_mismatch.yaml',
+        'workflow_v2_reverse.yaml',
     ]
 }
 
@@ -85,7 +87,7 @@
 ACTION_PARAMS = {'friend': 'Rocky'}
 NON_EMPTY_RESULT = 'non-empty'
 
-# Workbook with a single workflow
+# Workbook with a single workflow (direct)
 WB1_META_FILE_NAME = TEST_FIXTURES['workflows'][0]
 WB1_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WB1_META_FILE_NAME
 WB1_META_CONTENT = loader.load_meta_file(WB1_META_FILE_PATH)
@@ -99,7 +101,7 @@
 WB1_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
 WB1_EXEC['workflow_name'] = WB1_NAME
 
-# Workbook with many workflows
+# Workbook with many workflows (direct)
 WB2_META_FILE_NAME = TEST_FIXTURES['workflows'][1]
 WB2_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WB2_META_FILE_NAME
 WB2_META_CONTENT = loader.load_meta_file(WB2_META_FILE_PATH)
@@ -112,7 +114,7 @@
 WB2_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
 WB2_EXEC['workflow_name'] = WB2_NAME
 
-# Workbook with many workflows but no default workflow is defined
+# Workbook with many workflows (direct) but no default workflow is defined
 WB3_META_FILE_NAME = TEST_FIXTURES['workflows'][2]
 WB3_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WB3_META_FILE_NAME
 WB3_META_CONTENT = loader.load_meta_file(WB3_META_FILE_PATH)
@@ -125,7 +127,7 @@
 WB3_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
 WB3_EXEC['workflow_name'] = WB3_NAME
 
-# Non-workbook with a single workflow
+# Non-workbook with a single workflow (direct)
 WF1_META_FILE_NAME = TEST_FIXTURES['workflows'][3]
 WF1_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WF1_META_FILE_NAME
 WF1_META_CONTENT = loader.load_meta_file(WF1_META_FILE_PATH)
@@ -139,7 +141,7 @@
 WF1_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
 WF1_EXEC['workflow_name'] = WF1_NAME
 
-# Non-workbook with a many workflows
+# Non-workbook with a many workflows (direct)
 WF2_META_FILE_NAME = TEST_FIXTURES['workflows'][4]
 WF2_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WF2_META_FILE_NAME
 WF2_META_CONTENT = loader.load_meta_file(WF2_META_FILE_PATH)
@@ -152,6 +154,20 @@
 WF2_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
 WF2_EXEC['workflow_name'] = WF2_NAME
 
+# Non-workbook with a single workflow (reverse)
+WF3_META_FILE_NAME = TEST_FIXTURES['workflows'][5]
+WF3_META_FILE_PATH = TEST_PACK_PATH + '/actions/' + WF3_META_FILE_NAME
+WF3_META_CONTENT = loader.load_meta_file(WF3_META_FILE_PATH)
+WF3_NAME = WF3_META_CONTENT['pack'] + '.' + WF3_META_CONTENT['name']
+WF3_ENTRY_POINT = TEST_PACK_PATH + '/actions/' + WF3_META_CONTENT['entry_point']
+WF3_ENTRY_POINT_X = WF3_ENTRY_POINT.replace(WF3_META_FILE_NAME, 'xformed_' + WF3_META_FILE_NAME)
+WF3_SPEC = yaml.safe_load(MistralRunner.get_workflow_definition(WF3_ENTRY_POINT_X))
+WF3_YAML = yaml.safe_dump(WF3_SPEC, default_flow_style=False)
+WF3 = workflows.Workflow(None, {'name': WF3_NAME, 'definition': WF3_YAML})
+WF3_EXEC = copy.deepcopy(MISTRAL_EXECUTION)
+WF3_EXEC['workflow_name'] = WF3_NAME
+WF3_REVERSE_TARGET_TASK_NAME = WF3_META_CONTENT['parameters']['task_name']['default']
+
 # Data for the notify param
 NOTIFY = [{'type': 'st2'}]
 
@@ -764,6 +780,61 @@ def test_launch_workflow_name_mistmatch(self):
         liveaction = self._wait_on_status(liveaction, action_constants.LIVEACTION_STATUS_FAILED)
         self.assertIn('Name of the workflow must be the same', liveaction.result['error'])
 
+    @mock.patch.object(
+        workflows.WorkflowManager, 'list',
+        mock.MagicMock(return_value=[]))
+    @mock.patch.object(
+        workflows.WorkflowManager, 'get',
+        mock.MagicMock(return_value=WF3))
+    @mock.patch.object(
+        workflows.WorkflowManager, 'create',
+        mock.MagicMock(return_value=[WF3]))
+    @mock.patch.object(
+        executions.ExecutionManager, 'create',
+        mock.MagicMock(return_value=executions.Execution(None, WF3_EXEC)))
+    def test_launch_workflow_reverse(self):
+        # no differences for liveaction (direct == reverse)
+        liveaction = LiveActionDB(action=WF3_NAME, parameters=ACTION_PARAMS)
+        liveaction, execution = action_service.request(liveaction)
+        liveaction = self._wait_on_status(liveaction, action_constants.LIVEACTION_STATUS_RUNNING)
+
+        # no differences for mistral_context (direct == reverse)
+        mistral_context = liveaction.context.get('mistral', None)
+        self.assertIsNotNone(mistral_context)
+        self.assertEqual(mistral_context['execution_id'], WF3_EXEC.get('id'))
+        self.assertEqual(mistral_context['workflow_name'], WF3_EXEC.get('workflow_name'))
+
+        # no differences for workflow_input (direct == reverse)
+        workflow_input = copy.deepcopy(ACTION_PARAMS)
+        workflow_input.update({'count': '3'})
+
+        env = {
+            'st2_execution_id': str(execution.id),
+            'st2_liveaction_id': str(liveaction.id),
+            'st2_action_api_url': 'http://0.0.0.0:9101/v1',
+            '__actions': {
+                'st2.action': {
+                    'st2_context': {
+                        'api_url': 'http://0.0.0.0:9101/v1',
+                        'endpoint': 'http://0.0.0.0:9101/v1/actionexecutions',
+                        'parent': {
+                            'pack': 'mistral_tests',
+                            'execution_id': str(execution.id)
+                        },
+                        'notify': {},
+                        'skip_notify_tasks': []
+                    }
+                }
+            }
+        }
+
+        # task_name must be passed to mistral.executions.create for reverse workflows
+        task_name = WF3_REVERSE_TARGET_TASK_NAME
+
+        executions.ExecutionManager.create.assert_called_with(
+            WF3_NAME, workflow_input=workflow_input, env=env, notify=NOTIFY,
+            task_name=task_name)
+
     @mock.patch.object(
         workflows.WorkflowManager, 'list',
         mock.MagicMock(return_value=[]))
diff --git a/st2tests/integration/mistral/test_wiring.py b/st2tests/integration/mistral/test_wiring.py
--- a/st2tests/integration/mistral/test_wiring.py
+++ b/st2tests/integration/mistral/test_wiring.py
@@ -51,6 +51,21 @@ def test_basic_workflow(self):
         self.assertIn('stdout', ex.result)
         self.assertEqual(len(ex.result.get('tasks', [])), 1)
 
+    def test_basic_reverse_workflow(self):
+        ex = self._execute_workflow('examples.mistral-reverse-basic', {'cmd': 'date'})
+        ex = self._wait_for_completion(ex)
+        self.assertEqual(ex.status, action_constants.LIVEACTION_STATUS_SUCCEEDED)
+        self.assertIn('stdout', ex.result)
+        self.assertEqual(len(ex.result.get('tasks', [])), 1)
+
+    def test_reverse_workflow_with_requires(self):
+        params = {'question': 'life universe everything'}
+        ex = self._execute_workflow('examples.mistral-reverse-requires', params)
+        ex = self._wait_for_completion(ex)
+        self.assertEqual(ex.status, action_constants.LIVEACTION_STATUS_SUCCEEDED)
+        self.assertIn('answer', ex.result)
+        self.assertEqual(len(ex.result.get('tasks', [])), 5)
+
     def test_basic_workbook(self):
         ex = self._execute_workflow('examples.mistral-workbook-basic', {'cmd': 'date'})
         ex = self._wait_for_completion(ex)
diff --git a/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflow_v2_reverse.yaml b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflow_v2_reverse.yaml
new file mode 100644
--- /dev/null
+++ b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflow_v2_reverse.yaml
@@ -0,0 +1,19 @@
+---
+description: Say hi to friend!
+enabled: true
+entry_point: workflows/workflow_v2_reverse.yaml
+name: workflow_v2_reverse
+pack: mistral_tests
+parameters:
+  count:
+    default: '3'
+    type: string
+  friend:
+    required: true
+    type: string
+  task_name:
+    immutable: true
+    default: say-friend
+    type: string
+runner_type: mistral-v2
+
diff --git a/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/workflow_v2_reverse.yaml b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/workflow_v2_reverse.yaml
new file mode 100644
--- /dev/null
+++ b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/workflow_v2_reverse.yaml
@@ -0,0 +1,21 @@
+version: '2.0'
+
+mistral_tests.workflow_v2_reverse:
+    type: reverse
+    input:
+        - count
+        - friend
+    tasks:
+        say-greeting:
+            action: core.local
+            input:
+                cmd: <% $.count %>
+            publish:
+                greet: <% task(say-greeting).result.stdout %>
+        say-friend:
+            requires: [say-greeting]
+            action: core.local
+            input:
+                cmd: <% $.friend %>
+            publish:
+                towhom: <% task(say-friend).result.stdout %>
diff --git a/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/xformed_workflow_v2_reverse.yaml b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/xformed_workflow_v2_reverse.yaml
new file mode 100644
--- /dev/null
+++ b/st2tests/st2tests/fixtures/packs/mistral_tests/actions/workflows/xformed_workflow_v2_reverse.yaml
@@ -0,0 +1,25 @@
+version: '2.0'
+
+mistral_tests.workflow_v2_reverse:
+    type: reverse
+    input:
+        - count
+        - friend
+    tasks:
+        say-greeting:
+            action: st2.action
+            input:
+                ref: core.local
+                parameters:
+                    cmd: <% $.count %>
+            publish:
+                greet: <% task(say-greeting).result.stdout %>
+        say-friend:
+            requires: [say-greeting]
+            action: st2.action
+            input:
+                ref: core.local
+                parameters:
+                    cmd: <% $.friend %>
+            publish:
+                towhom: <% task(say-friend).result.stdout %>