You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@openwhisk.apache.org by GitBox <gi...@apache.org> on 2018/10/03 19:40:23 UTC

[GitHub] dgrove-oss closed pull request #1: Initial import of the Composer code

dgrove-oss closed pull request #1: Initial import of the Composer code
URL: https://github.com/apache/incubator-openwhisk-composer/pull/1
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ef84937
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+openwhisk
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..cf0f091
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,27 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+language: node_js
+node_js:
+  - 6
+services:
+  - docker
+env:
+  global:
+    - IGNORE_CERTS=true
+before_script:
+  - ./travis/setup.sh
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8dada3e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da2d59b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,144 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# @ibm-functions/composer
+
+[![Travis](https://travis-ci.org/ibm-functions/composer.svg?branch=master)](https://travis-ci.org/ibm-functions/composer)
+[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
+[![Join
+Slack](https://img.shields.io/badge/join-slack-9B69A0.svg)](http://slack.openwhisk.org/)
+
+Composer is a new programming model for composing cloud functions built on
+[Apache OpenWhisk](https://github.com/apache/incubator-openwhisk). With
+Composer, developers can build even more serverless applications including using
+it for IoT, with workflow orchestration, conversation services, and devops
+automation, to name a few examples.
+
+Composer synthesizes OpenWhisk [conductor
+actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md)
+to implement compositions. Compositions have all the attributes and capabilities
+of an action, e.g., default parameters, limits, blocking invocation, web export.
+
+This repository includes:
+* the [composer](composer.js) Node.js module for authoring compositions using
+  JavaScript,
+* the [compose](bin/compose.js) and [deploy](bin/deploy.js)
+  [commands](docs/COMMANDS.md) for compiling and deploying compositions,
+* [documentation](docs), [examples](samples), and [tests](test).
+
+## Installation
+
+Composer is distributed as Node.js package. To install this package, use the
+Node Package Manager:
+```
+npm install -g @ibm-functions/composer
+```
+We recommend to install the package globally (with `-g` option) if you intend to
+use the `compose` and `deploy` commands to compile and deploy compositions.
+
+## Defining a composition
+
+A composition is typically defined by means of a Javascript expression as
+illustrated in [samples/demo.js](samples/demo.js):
+```javascript
+const composer = require('@ibm-functions/composer')
+
+module.exports = composer.if(
+    composer.action('authenticate', { action: function ({ password }) { return { value: password === 'abc123' } } }),
+    composer.action('success', { action: function () { return { message: 'success' } } }),
+    composer.action('failure', { action: function () { return { message: 'failure' } } }))
+```
+Compositions compose actions using [combinator](docs/COMBINATORS.md) methods.
+These methods implement the typical control-flow constructs of a sequential
+imperative programming language. This example composition composes three actions
+named `authenticate`, `success`, and `failure` using the `composer.if`
+combinator, which implements the usual conditional construct. It take three
+actions (or compositions) as parameters. It invokes the first one and, depending
+on the result of this invocation, invokes either the second or third action.
+
+ This composition includes the definitions of the three composed actions. If the
+ actions are defined and deployed elsewhere, the composition code can be shorten
+ to:
+```javascript
+composer.if('authenticate', 'success', 'failure')
+```
+
+## Deploying a composition
+
+One way to deploy a composition is to use the `compose` and `deploy` commands:
+```
+compose demo.js > demo.json
+deploy demo demo.json -w
+```
+```
+ok: created /_/authenticate,/_/success,/_/failure,/_/demo
+```
+The `compose` command compiles the composition code to a portable JSON format.
+The `deploy` command deploys the JSON-encoded composition creating an action
+with the given name. It also deploys the composed actions if definitions are
+provided for them. The `-w` option authorizes the `deploy` command to overwrite
+existing definitions.
+
+## Running a composition
+
+The `demo` composition may be invoked like any action, for instance using the
+OpenWhisk CLI:
+```
+wsk action invoke demo -p password passw0rd
+```
+```
+ok: invoked /_/demo with id 4f91f9ed0d874aaa91f9ed0d87baaa07
+```
+The result of this invocation is the result of the last action in the
+composition, in this case the `failure` action since the password in incorrect:
+```
+wsk activation result 4f91f9ed0d874aaa91f9ed0d87baaa07
+```
+```json
+{
+    "message": "failure"
+}
+```
+### Execution traces
+
+This invocation creates a trace, i.e., a series of activation records:
+```
+wsk activation list
+```
+```
+activations
+fd89b99a90a1462a89b99a90a1d62a8e demo
+eaec119273d94087ac119273d90087d0 failure
+3624ad829d4044afa4ad829d40e4af60 demo
+a1f58ade9b1e4c26b58ade9b1e4c2614 authenticate
+3624ad829d4044afa4ad829d40e4af60 demo
+4f91f9ed0d874aaa91f9ed0d87baaa07 demo
+```
+The entry with the earliest start time (`4f91f9ed0d874aaa91f9ed0d87baaa07`)
+summarizes the invocation of the composition while other entries record later
+activations caused by the composition invocation. There is one entry for each
+invocation of a composed action (`a1f58ade9b1e4c26b58ade9b1e4c2614` and
+`eaec119273d94087ac119273d90087d0`). The remaining entries record the beginning
+and end of the composition as well as the transitions between the composed
+actions.
+
+Compositions are implemented by means of OpenWhisk conductor actions. The
+[documentation of conductor
+actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md)
+explains execution traces in greater details.
diff --git a/bin/compose.js b/bin/compose.js
new file mode 100755
index 0000000..f0aa334
--- /dev/null
+++ b/bin/compose.js
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict'
+
+const composer = require('../composer')
+const json = require('../package.json')
+const minimist = require('minimist')
+const Module = require('module')
+const path = require('path')
+
+const argv = minimist(process.argv.slice(2), {
+  boolean: ['version', 'ast'],
+  alias: { version: 'v' }
+})
+
+if (argv.version) {
+  console.log(json.version)
+  process.exit(0)
+}
+
+// resolve module even if not in default path
+const _resolveFilename = Module._resolveFilename
+Module._resolveFilename = function (request, parent) {
+  if (request.startsWith(json.name)) {
+    try {
+      return _resolveFilename(request, parent)
+    } catch (error) {
+      return require.resolve(request.replace(request.startsWith(json.name + '/') ? json.name : json.name.substring(0, json.name.indexOf('/')), '..'))
+    }
+  } else {
+    return _resolveFilename(request, parent)
+  }
+}
+
+if (argv._.length !== 1 || path.extname(argv._[0]) !== '.js') {
+  console.error('Usage:')
+  console.error('  compose composition.js [flags]')
+  console.error('Flags:')
+  console.error('  --ast                  only output the ast for the composition')
+  console.error('  -v, --version          output the composer version')
+  process.exit(1)
+}
+
+let composition
+try {
+  composition = composer.parse(require(path.resolve(argv._[0]))) // load and validate composition
+  composition = composition.compile()
+} catch (error) {
+  error.statusCode = 422
+  console.error(error)
+  process.exit(422 - 256) // Unprocessable Entity
+}
+if (argv.ast) composition = composition.ast
+console.log(JSON.stringify(composition, null, 4))
diff --git a/bin/deploy.js b/bin/deploy.js
new file mode 100755
index 0000000..ec1343c
--- /dev/null
+++ b/bin/deploy.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict'
+
+const composer = require('../composer')
+const conductor = require('../conductor')
+const fqn = require('../fqn')
+const fs = require('fs')
+const json = require('../package.json')
+const minimist = require('minimist')
+const path = require('path')
+
+const argv = minimist(process.argv.slice(2), {
+  string: ['apihost', 'auth', 'source', 'annotation', 'annotation-file'],
+  boolean: ['insecure', 'version', 'overwrite'],
+  alias: { auth: 'u', insecure: 'i', version: 'v', annotation: 'a', 'annotation-file': 'A', overwrite: 'w' }
+})
+
+if (argv.version) {
+  console.log(json.version)
+  process.exit(0)
+}
+
+if (argv._.length !== 2 || path.extname(argv._[1]) !== '.json') {
+  console.error('Usage:')
+  console.error('  deploy composition composition.json [flags]')
+  console.error('Flags:')
+  console.error('  -a, --annotation KEY=VALUE        add KEY annotation with VALUE')
+  console.error('  -A, --annotation-file KEY=FILE    add KEY annotation with FILE content')
+  console.error('  --apihost HOST                    API HOST')
+  console.error('  -i, --insecure                    bypass certificate checking')
+  console.error('  -u, --auth KEY                    authorization KEY')
+  console.error('  -v, --version                     output the composer version')
+  console.error('  -w, --overwrite                   overwrite actions if already defined')
+  process.exit(1)
+}
+let composition
+try {
+  composition = JSON.parse(fs.readFileSync(argv._[1], 'utf8'))
+  if (typeof composition !== 'object') throw new Error('Composition must be a dictionary')
+  if (typeof composition.ast !== 'object') throw new Error('Composition must have a field "ast" of type dictionary')
+  if (typeof composition.composition !== 'object') throw new Error('Composition must have a field "composition" of type dictionary')
+  if (typeof composition.version !== 'string') throw new Error('Composition must have a field "version" of type string')
+  if (composition.actions !== undefined && !Array.isArray(composition.actions)) throw new Error('Optional field "actions" must be an array')
+  composition.composition = composer.parse(composition.composition) // validate composition
+  if (typeof argv.annotation === 'string') argv.annotation = [argv.annotation]
+  composition.annotations = []
+  for (let annotation of [...(argv.annotation || [])]) {
+    const index = annotation.indexOf('=')
+    if (index < 0) throw Error('Annotation syntax must be "KEY=VALUE"')
+    composition.annotations.push({ key: annotation.substring(0, index), value: annotation.substring(index + 1) })
+  }
+  if (typeof argv['annotation-file'] === 'string') argv['annotation-file'] = [argv['annotation-file']]
+  for (let annotation of argv['annotation-file'] || []) {
+    const index = annotation.indexOf('=')
+    if (index < 0) throw Error('Annotation syntax must be "KEY=FILE"')
+    composition.annotations.push({ key: annotation.substring(0, index), value: fs.readFileSync(annotation.substring(index + 1), 'utf8') })
+  }
+} catch (error) {
+  error.statusCode = 422
+  console.error(error)
+  process.exit(422 - 256) // Unprocessable Entity
+}
+const options = { ignore_certs: argv.insecure }
+if (argv.apihost) options.apihost = argv.apihost
+if (argv.auth) options.api_key = argv.auth
+try {
+  composition.name = fqn(argv._[0])
+} catch (error) {
+  error.statusCode = 400
+  console.error(error)
+  process.exit(400 - 256) // Bad Request
+}
+conductor(options).compositions.deploy(composition, argv.overwrite)
+  .then(actions => {
+    const names = actions.map(action => action.name)
+    console.log(`ok: created action${actions.length > 1 ? 's' : ''} ${names}`)
+  })
+  .catch(error => {
+    error.statusCode = error.statusCode || 500
+    console.error(error)
+    process.exit(error.statusCode - 256)
+  })
diff --git a/composer.js b/composer.js
new file mode 100644
index 0000000..ed3ef97
--- /dev/null
+++ b/composer.js
@@ -0,0 +1,371 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict'
+
+const fqn = require('./fqn')
+const fs = require('fs')
+const util = require('util')
+
+const version = require('./package.json').version
+
+const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj)
+
+// error class
+class ComposerError extends Error {
+  constructor (message, argument) {
+    super(message + (argument !== undefined ? '\nArgument value: ' + util.inspect(argument) : ''))
+  }
+}
+
+const composer = { util: { declare, version } }
+
+const lowerer = {
+  literal (value) {
+    return composer.let({ value }, () => value)
+  },
+
+  retain (...components) {
+    let params = null
+    return composer.let(
+      { params },
+      composer.finally(
+        args => { params = args },
+        composer.seq(composer.mask(...components),
+          result => ({ params, result }))))
+  },
+
+  retain_catch (...components) {
+    return composer.seq(
+      composer.retain(
+        composer.finally(
+          composer.seq(...components),
+          result => ({ result }))),
+      ({ params, result }) => ({ params, result: result.result }))
+  },
+
+  if (test, consequent, alternate) {
+    let params = null
+    return composer.let(
+      { params },
+      composer.finally(
+        args => { params = args },
+        composer.if_nosave(
+          composer.mask(test),
+          composer.finally(() => params, composer.mask(consequent)),
+          composer.finally(() => params, composer.mask(alternate)))))
+  },
+
+  while (test, body) {
+    let params = null
+    return composer.let(
+      { params },
+      composer.finally(
+        args => { params = args },
+        composer.seq(composer.while_nosave(
+          composer.mask(test),
+          composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args }))),
+        () => params)))
+  },
+
+  dowhile (body, test) {
+    let params = null
+    return composer.let(
+      { params },
+      composer.finally(
+        args => { params = args },
+        composer.seq(composer.dowhile_nosave(
+          composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args })),
+          composer.mask(test)),
+        () => params)))
+  },
+
+  repeat (count, ...components) {
+    return composer.let(
+      { count },
+      composer.while(
+        () => count-- > 0,
+        composer.mask(...components)))
+  },
+
+  retry (count, ...components) {
+    return composer.let(
+      { count },
+      params => ({ params }),
+      composer.dowhile(
+        composer.finally(({ params }) => params, composer.mask(composer.retain_catch(...components))),
+        ({ result }) => result.error !== undefined && count-- > 0),
+      ({ result }) => result)
+  },
+
+  merge (...components) {
+    return composer.seq(composer.retain(...components), ({ params, result }) => Object.assign(params, result))
+  }
+}
+
+// apply f to all fields of type composition
+function visit (composition, f) {
+  composition = Object.assign({}, composition) // copy
+  const combinator = composition['.combinator']()
+  if (combinator.components) {
+    composition.components = composition.components.map(f)
+  }
+  for (let arg of combinator.args || []) {
+    if (arg.type === undefined && composition[arg.name] !== undefined) {
+      composition[arg.name] = f(composition[arg.name], arg.name)
+    }
+  }
+  return new Composition(composition)
+}
+
+// recursively label combinators with the json path
+function label (composition) {
+  const label = path => (composition, name, array) => {
+    const p = path + (name !== undefined ? (array === undefined ? `.${name}` : `[${name}]`) : '')
+    composition = visit(composition, label(p)) // copy
+    composition.path = p
+    return composition
+  }
+  return label('')(composition)
+}
+
+// derive combinator methods from combinator table
+// check argument count and map argument positions to argument names
+// delegate to Composition constructor for the rest of the validation
+function declare (combinators, prefix) {
+  if (arguments.length > 2) throw new ComposerError('Too many arguments in "declare"')
+  if (!isObject(combinators)) throw new ComposerError('Invalid argument "combinators" in "declare"', combinators)
+  if (prefix !== undefined && typeof prefix !== 'string') throw new ComposerError('Invalid argument "prefix" in "declare"', prefix)
+  const composer = {}
+  for (let key in combinators) {
+    const type = prefix ? prefix + '.' + key : key
+    const combinator = combinators[key]
+    if (!isObject(combinator) || (combinator.args !== undefined && !Array.isArray(combinator.args))) {
+      throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator)
+    }
+    for (let arg of combinator.args || []) {
+      if (typeof arg.name !== 'string') throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator)
+    }
+    composer[key] = function () {
+      const composition = { type, '.combinator': () => combinator }
+      const skip = (combinator.args && combinator.args.length) || 0
+      if (!combinator.components && (arguments.length > skip)) {
+        throw new ComposerError(`Too many arguments in "${type}" combinator`)
+      }
+      for (let i = 0; i < skip; ++i) {
+        composition[combinator.args[i].name] = arguments[i]
+      }
+      if (combinator.components) {
+        composition.components = Array.prototype.slice.call(arguments, skip)
+      }
+      return new Composition(composition)
+    }
+  }
+  return composer
+}
+
+// composition class
+class Composition {
+  // weaker instanceof to tolerate multiple instances of this class
+  static [Symbol.hasInstance] (instance) {
+    return instance.constructor && instance.constructor.name === Composition.name
+  }
+
+  // construct a composition object with the specified fields
+  constructor (composition) {
+    const combinator = composition['.combinator']()
+    Object.assign(this, composition)
+    for (let arg of combinator.args || []) {
+      if (composition[arg.name] === undefined && arg.optional && arg.type !== undefined) continue
+      switch (arg.type) {
+        case undefined:
+          try {
+            this[arg.name] = composer.task(arg.optional ? composition[arg.name] || null : composition[arg.name])
+          } catch (error) {
+            throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
+          }
+          break
+        case 'name':
+          try {
+            this[arg.name] = fqn(composition[arg.name])
+          } catch (error) {
+            throw new ComposerError(`${error.message} in "${composition.type} combinator"`, composition[arg.name])
+          }
+          break
+        case 'value':
+          if (typeof composition[arg.name] === 'function' || composition[arg.name] === undefined) {
+            throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
+          }
+          break
+        case 'object':
+          if (!isObject(composition[arg.name])) {
+            throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
+          }
+          break
+        default:
+          if ('' + typeof composition[arg.name] !== arg.type) {
+            throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name])
+          }
+      }
+    }
+    if (combinator.components) this.components = (composition.components || []).map(obj => composer.task(obj))
+    return this
+  }
+
+  // compile composition
+  compile () {
+    if (arguments.length > 0) throw new ComposerError('Too many arguments in "compile"')
+
+    const actions = []
+
+    const flatten = composition => {
+      composition = visit(composition, flatten)
+      if (composition.type === 'action' && composition.action) {
+        actions.push({ name: composition.name, action: composition.action })
+        delete composition.action
+      }
+      return composition
+    }
+
+    const obj = { composition: label(flatten(this)).lower(), ast: this, version }
+    if (actions.length > 0) obj.actions = actions
+    return obj
+  }
+
+  // recursively lower combinators to the desired set of combinators (including primitive combinators)
+  lower (combinators = []) {
+    if (arguments.length > 1) throw new ComposerError('Too many arguments in "lower"')
+    if (!Array.isArray(combinators)) throw new ComposerError('Invalid argument "combinators" in "lower"', combinators)
+
+    const lower = composition => {
+      // repeatedly lower root combinator
+      while (composition['.combinator']().def) {
+        const path = composition.path
+        const combinator = composition['.combinator']()
+        if (Array.isArray(combinators) && combinators.indexOf(composition.type) >= 0) break
+        // map argument names to positions
+        const args = []
+        const skip = (combinator.args && combinator.args.length) || 0
+        for (let i = 0; i < skip; i++) args.push(composition[combinator.args[i].name])
+        if (combinator.components) args.push(...composition.components)
+        composition = combinator.def(...args)
+        if (path !== undefined) composition.path = path // preserve path
+      }
+      // lower nested combinators
+      return visit(composition, lower)
+    }
+
+    return lower(this)
+  }
+}
+
+// primitive combinators
+const combinators = {
+  sequence: { components: true },
+  if_nosave: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }] },
+  while_nosave: { args: [{ name: 'test' }, { name: 'body' }] },
+  dowhile_nosave: { args: [{ name: 'body' }, { name: 'test' }] },
+  try: { args: [{ name: 'body' }, { name: 'handler' }] },
+  finally: { args: [{ name: 'body' }, { name: 'finalizer' }] },
+  let: { args: [{ name: 'declarations', type: 'object' }], components: true },
+  mask: { components: true },
+  action: { args: [{ name: 'name', type: 'name' }, { name: 'action', type: 'object', optional: true }] },
+  function: { args: [{ name: 'function', type: 'object' }] },
+  async: { components: true }
+}
+
+Object.assign(composer, declare(combinators))
+
+// derived combinators
+const extra = {
+  empty: { def: composer.sequence },
+  seq: { components: true, def: composer.sequence },
+  if: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }], def: lowerer.if },
+  while: { args: [{ name: 'test' }, { name: 'body' }], def: lowerer.while },
+  dowhile: { args: [{ name: 'body' }, { name: 'test' }], def: lowerer.dowhile },
+  repeat: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.repeat },
+  retry: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.retry },
+  retain: { components: true, def: lowerer.retain },
+  retain_catch: { components: true, def: lowerer.retain_catch },
+  value: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
+  literal: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
+  merge: { components: true, def: lowerer.merge }
+}
+
+Object.assign(composer, declare(extra))
+
+// add or override definitions of some combinators
+Object.assign(composer, {
+  // detect task type and create corresponding composition object
+  task (task) {
+    if (arguments.length > 1) throw new ComposerError('Too many arguments in "task" combinator')
+    if (task === undefined) throw new ComposerError('Invalid argument in "task" combinator', task)
+    if (task === null) return composer.empty()
+    if (task instanceof Composition) return task
+    if (typeof task === 'function') return composer.function(task)
+    if (typeof task === 'string') return composer.action(task)
+    throw new ComposerError('Invalid argument "task" in "task" combinator', task)
+  },
+
+  // function combinator: stringify function code
+  function (fun) {
+    if (arguments.length > 1) throw new ComposerError('Too many arguments in "function" combinator')
+    if (typeof fun === 'function') {
+      fun = `${fun}`
+      if (fun.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "function" combinator', fun)
+    }
+    if (typeof fun === 'string') {
+      fun = { kind: 'nodejs:default', code: fun }
+    }
+    if (!isObject(fun)) throw new ComposerError('Invalid argument "function" in "function" combinator', fun)
+    return new Composition({ type: 'function', function: { exec: fun }, '.combinator': () => combinators.function })
+  },
+
+  // action combinator
+  action (name, options = {}) {
+    if (arguments.length > 2) throw new ComposerError('Too many arguments in "action" combinator')
+    if (!isObject(options)) throw new ComposerError('Invalid argument "options" in "action" combinator', options)
+    let exec
+    if (Array.isArray(options.sequence)) { // native sequence
+      exec = { kind: 'sequence', components: options.sequence.map(fqn) }
+    } else if (typeof options.filename === 'string') { // read action code from file
+      exec = fs.readFileSync(options.filename, { encoding: 'utf8' })
+    } else if (typeof options.action === 'function') { // capture function
+      exec = `const main = ${options.action}`
+      if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "action" combinator', options.action)
+    } else if (typeof options.action === 'string' || isObject(options.action)) {
+      exec = options.action
+    }
+    if (typeof exec === 'string') {
+      exec = { kind: 'nodejs:default', code: exec }
+    }
+    const composition = { type: 'action', name, '.combinator': () => combinators.action }
+    if (exec) composition.action = { exec }
+    return new Composition(composition)
+  },
+
+  // recursively deserialize composition
+  parse (composition) {
+    if (arguments.length > 1) throw new ComposerError('Too many arguments in "parse" combinator')
+    if (!isObject(composition)) throw new ComposerError('Invalid argument "composition" in "parse" combinator', composition)
+    const combinator = typeof composition['.combinator'] === 'function' ? composition['.combinator']() : combinators[composition.type]
+    if (!isObject(combinator)) throw new ComposerError('Invalid composition type in "parse" combinator', composition)
+    return visit(Object.assign({ '.combinator': () => combinator }, composition), composition => composer.parse(composition))
+  }
+})
+
+module.exports = composer
diff --git a/conductor.js b/conductor.js
new file mode 100644
index 0000000..0764018
--- /dev/null
+++ b/conductor.js
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* eslint no-eval: 0 */
+
+'use strict'
+
+const fs = require('fs')
+const { minify } = require('terser')
+const openwhisk = require('openwhisk')
+const os = require('os')
+const path = require('path')
+
+// read conductor version number
+const version = require('./package.json').version
+
+// synthesize conductor action code from composition
+function synthesize ({ name, composition, ast, version: composer, annotations = [] }) {
+  const code = `// generated by composer v${composer} and conductor v${version}\n\nconst composition = ${JSON.stringify(composition, null, 4)}\n\n// do not edit below this point\n\n` +
+    minify(`const main=(${main})(composition)`, { output: { max_line_len: 127 } }).code
+  annotations = annotations.concat([{ key: 'conductor', value: ast }, { key: 'composerVersion', value: composer }, { key: 'conductorVersion', value: version }])
+  return { name, action: { exec: { kind: 'nodejs:default', code }, annotations } }
+}
+
+// return enhanced openwhisk client capable of deploying compositions
+module.exports = function (options) {
+  // try to extract apihost and key first from whisk property file file and then from process.env
+  let apihost
+  let apikey
+
+  try {
+    const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops')
+    const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n')
+
+    for (let line of lines) {
+      let parts = line.trim().split('=')
+      if (parts.length === 2) {
+        if (parts[0] === 'APIHOST') {
+          apihost = parts[1]
+        } else if (parts[0] === 'AUTH') {
+          apikey = parts[1]
+        }
+      }
+    }
+  } catch (error) { }
+
+  if (process.env.__OW_API_HOST) apihost = process.env.__OW_API_HOST
+  if (process.env.__OW_API_KEY) apikey = process.env.__OW_API_KEY
+
+  const wsk = openwhisk(Object.assign({ apihost, api_key: apikey }, options))
+  wsk.compositions = new Compositions(wsk)
+  return wsk
+}
+
+// management class for compositions
+class Compositions {
+  constructor (wsk) {
+    this.actions = wsk.actions
+  }
+
+  deploy (composition, overwrite) {
+    const actions = (composition.actions || []).concat(synthesize(composition))
+    return actions.reduce((promise, action) => promise.then(() => overwrite && this.actions.delete(action).catch(() => { }))
+      .then(() => this.actions.create(action)), Promise.resolve())
+      .then(() => actions)
+  }
+}
+
+// runtime code
+function main (composition) {
+  const openwhisk = require('openwhisk')
+  let wsk
+
+  const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj)
+
+  // compile ast to fsm
+  const compiler = {
+    sequence (parent, node) {
+      return [{ parent, type: 'pass' }, ...compile(parent, ...node.components)]
+    },
+
+    action (parent, node) {
+      return [{ parent, type: 'action', name: node.name }]
+    },
+
+    async (parent, node) {
+      const body = [...compile(parent, ...node.components)]
+      return [{ parent, type: 'async', return: body.length + 2 }, ...body, { parent, type: 'stop' }, { parent, type: 'pass' }]
+    },
+
+    function (parent, node) {
+      return [{ parent, type: 'function', exec: node.function.exec }]
+    },
+
+    finally (parent, node) {
+      const finalizer = compile(parent, node.finalizer)
+      const fsm = [{ parent, type: 'try' }, ...compile(parent, node.body), { parent, type: 'exit' }, ...finalizer]
+      fsm[0].catch = fsm.length - finalizer.length
+      return fsm
+    },
+
+    let (parent, node) {
+      return [{ parent, type: 'let', let: node.declarations }, ...compile(parent, ...node.components), { parent, type: 'exit' }]
+    },
+
+    mask (parent, node) {
+      return [{ parent, type: 'let', let: null }, ...compile(parent, ...node.components), { parent, type: 'exit' }]
+    },
+
+    try (parent, node) {
+      const handler = [...compile(parent, node.handler), { parent, type: 'pass' }]
+      const fsm = [{ parent, type: 'try' }, ...compile(parent, node.body), { parent, type: 'exit' }, ...handler]
+      fsm[0].catch = fsm.length - handler.length
+      fsm[fsm.length - handler.length - 1].next = handler.length
+      return fsm
+    },
+
+    if_nosave (parent, node) {
+      const consequent = compile(parent, node.consequent)
+      const alternate = [...compile(parent, node.alternate), { parent, type: 'pass' }]
+      const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.test), { parent, type: 'choice', then: 1, else: consequent.length + 1 }, ...consequent, ...alternate]
+      fsm[fsm.length - alternate.length - 1].next = alternate.length
+      return fsm
+    },
+
+    while_nosave (parent, node) {
+      const body = compile(parent, node.body)
+      const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.test), { parent, type: 'choice', then: 1, else: body.length + 1 }, ...body, { parent, type: 'pass' }]
+      fsm[fsm.length - 2].next = 2 - fsm.length
+      return fsm
+    },
+
+    dowhile_nosave (parent, node) {
+      const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.body), ...compile(parent, node.test), { parent, type: 'choice', else: 1 }, { parent, type: 'pass' }]
+      fsm[fsm.length - 2].then = 2 - fsm.length
+      return fsm
+    }
+  }
+
+  function compile (parent, node) {
+    if (arguments.length === 1) return [{ parent, type: 'empty' }]
+    if (arguments.length === 2) return Object.assign(compiler[node.type](node.path || parent, node), { path: node.path })
+    return Array.prototype.slice.call(arguments, 1).reduce((fsm, node) => { fsm.push(...compile(parent, node)); return fsm }, [])
+  }
+
+  const fsm = compile('', composition)
+
+  const conductor = {
+    choice ({ p, node, index }) {
+      p.s.state = index + (p.params.value ? node.then : node.else)
+    },
+
+    try ({ p, node, index }) {
+      p.s.stack.unshift({ catch: index + node.catch })
+    },
+
+    let ({ p, node, index }) {
+      p.s.stack.unshift({ let: JSON.parse(JSON.stringify(node.let)) })
+    },
+
+    exit ({ p, node, index }) {
+      if (p.s.stack.length === 0) return internalError(`pop from an empty stack`)
+      p.s.stack.shift()
+    },
+
+    action ({ p, node, index }) {
+      return { method: 'action', action: node.name, params: p.params, state: { $resume: p.s } }
+    },
+
+    function ({ p, node, index }) {
+      return Promise.resolve().then(() => run(node.exec.code, p))
+        .catch(error => {
+          console.error(error)
+          return { error: `Function combinator threw an exception at AST node root${node.parent} (see log for details)` }
+        })
+        .then(result => {
+          if (typeof result === 'function') result = { error: `Function combinator evaluated to a function type at AST node root${node.parent}` }
+          // if a function has only side effects and no return value, return params
+          p.params = JSON.parse(JSON.stringify(result === undefined ? p.params : result))
+          inspect(p)
+          return step(p)
+        })
+    },
+
+    empty ({ p, node, index }) {
+      inspect(p)
+    },
+
+    pass ({ p, node, index }) {
+    },
+
+    async ({ p, node, index, inspect, step }) {
+      p.params.$resume = { state: p.s.state, stack: [{ marker: true }].concat(p.s.stack) }
+      p.s.state = index + node.return
+      if (!wsk) wsk = openwhisk({ ignore_certs: true })
+      return wsk.actions.invoke({ name: process.env.__OW_ACTION_NAME, params: p.params })
+        .then(response => ({ method: 'async', activationId: response.activationId, sessionId: p.s.session }), error => {
+          console.error(error) // invoke failed
+          return { error: `Async combinator failed to invoke composition at AST node root${node.parent} (see log for details)` }
+        })
+        .then(result => {
+          p.params = result
+          inspect(p)
+          return step(p)
+        })
+    },
+
+    stop ({ p, node, index, inspect, step }) {
+      p.s.state = -1
+    }
+  }
+
+  function finish (p) {
+    return p.params.error ? p.params : { params: p.params }
+  }
+
+  const internalError = error => Promise.reject(error) // terminate composition execution and record error
+
+  // wrap params if not a dictionary, branch to error handler if error
+  function inspect (p) {
+    if (!isObject(p.params)) p.params = { value: p.params }
+    if (p.params.error !== undefined) {
+      p.params = { error: p.params.error } // discard all fields but the error field
+      p.s.state = -1 // abort unless there is a handler in the stack
+      while (p.s.stack.length > 0 && !p.s.stack[0].marker) {
+        if ((p.s.state = p.s.stack.shift().catch || -1) >= 0) break
+      }
+    }
+  }
+
+  // run function f on current stack
+  function run (f, p) {
+    // handle let/mask pairs
+    const view = []
+    let n = 0
+    for (let frame of p.s.stack) {
+      if (frame.let === null) {
+        n++
+      } else if (frame.let !== undefined) {
+        if (n === 0) {
+          view.push(frame)
+        } else {
+          n--
+        }
+      }
+    }
+
+    // update value of topmost matching symbol on stack if any
+    function set (symbol, value) {
+      const element = view.find(element => element.let !== undefined && element.let[symbol] !== undefined)
+      if (element !== undefined) element.let[symbol] = JSON.parse(JSON.stringify(value))
+    }
+
+    // collapse stack for invocation
+    const env = view.reduceRight((acc, cur) => cur.let ? Object.assign(acc, cur.let) : acc, {})
+    let main = '(function(){try{const require=arguments[2];'
+    for (const name in env) main += `var ${name}=arguments[1]['${name}'];`
+    main += `return eval((function(){return(${f})})())(arguments[0])}finally{`
+    for (const name in env) main += `arguments[1]['${name}']=${name};`
+    main += '}})'
+    try {
+      return (1, eval)(main)(p.params, env, require)
+    } finally {
+      for (const name in env) set(name, env[name])
+    }
+  }
+
+  function step (p) {
+    // final state, return composition result
+    if (p.s.state < 0 || p.s.state >= fsm.length) {
+      console.log(`Entering final state`)
+      console.log(JSON.stringify(p.params))
+      return
+    }
+
+    // process one state
+    const node = fsm[p.s.state] // json definition for index state
+    if (node.path !== undefined) console.log(`Entering composition${node.path}`)
+    const index = p.s.state // current state
+    p.s.state = p.s.state + (node.next || 1) // default next state
+    if (typeof conductor[node.type] !== 'function') return internalError(`unexpected "${node.type}" combinator`)
+    return conductor[node.type]({ p, index, node, inspect, step }) || step(p)
+  }
+
+  // do invocation
+  return (params) => {
+    // extract parameters
+    const $resume = params.$resume || {}
+    delete params.$resume
+    $resume.session = $resume.session || process.env.__OW_ACTIVATION_ID
+
+    // current state
+    const p = { s: Object.assign({ state: 0, stack: [], resuming: true }, $resume), params }
+
+    // step and catch all errors
+    return Promise.resolve().then(() => {
+      if (typeof p.s.state !== 'number') return internalError('state parameter is not a number')
+      if (!Array.isArray(p.s.stack)) return internalError('stack parameter is not an array')
+
+      if ($resume.resuming) inspect(p) // handle error objects when resuming
+
+      return step(p)
+    }).catch(error => {
+      const message = (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error)
+      p.params = { error: message ? `Internal error: ${message}` : 'Internal error' }
+    }).then(params => params || finish(p)) // params is defined iff execution will be resumed
+  }
+}
diff --git a/docs/COMBINATORS.md b/docs/COMBINATORS.md
new file mode 100644
index 0000000..28540e9
--- /dev/null
+++ b/docs/COMBINATORS.md
@@ -0,0 +1,427 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# Combinators
+
+The `composer` module offers a number of combinators to define compositions:
+
+| Combinator | Description | Example |
+| --:| --- | --- |
+| [`action`](#action) | named action | `composer.action('echo')` |
+| [`async`](#async) | asynchronous invocation | `composer.async('compress', 'upload')` |
+| [`dowhile` and `dowhile_nosave`](#dowhile) | loop at least once | `composer.dowhile('fetchData', 'needMoreData')` |
+| [`empty`](#empty) | empty sequence | `composer.empty()`
+| [`finally`](#finally) | finalization | `composer.finally('tryThis', 'doThatAlways')` |
+| [`function`](#function) | Javascript function | `composer.function(({ x, y }) => ({ product: x * y }))` |
+| [`if` and `if_nosave`](#if) | conditional | `composer.if('authenticate', 'success', 'failure')` |
+| [`let`](#let) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)` |
+| [`literal` or `value`](#literal) | constant value | `composer.literal({ message: 'Hello, World!' })` |
+| [`mask`](#mask) | variable hiding | `composer.let({ n }, composer.while(_ => n-- > 0, composer.mask(composition)))` |
+| [`merge`](#merge) | data augmentation | `composer.merge('hash')` |
+| [`repeat`](#repeat) | counted loop | `composer.repeat(3, 'hello')` |
+| [`retain` and `retain_catch`](#retain) | persistence | `composer.retain('validateInput')` |
+| [`retry`](#retry) | error recovery | `composer.retry(3, 'connect')` |
+| [`sequence` or `seq`](#sequence) | sequence | `composer.sequence('hello', 'bye')` |
+| [`task`](#task) | single task | `composer.task('echo')`
+| [`try`](#try) | error handling | `composer.try('divideByN', 'NaN')` |
+| [`while` and `while_nosave`](#while) | loop | `composer.while('notEnough', 'doMore')` |
+
+The `action`, `function`, and `literal` combinators construct compositions
+respectively from OpenWhisk actions, Javascript functions, and constant values.
+The other combinators combine existing compositions to produce new compositions.
+
+## Shorthands
+
+Where a composition is expected, the following shorthands are permitted:
+ - `name` of type `string` stands for `composer.action(name)`,
+ - `fun` of type `function` stands for `composer.function(fun)`,
+ - `null` stands for the empty sequence `composer.empty()`.
+
+## Action
+
+`composer.action(name, [options])` is a composition with a single action named
+_name_. It invokes the action named _name_ on the input parameter object for the
+composition and returns the output parameter object of this action invocation.
+
+The action _name_ may specify the namespace and/or package containing the action
+following the usual OpenWhisk grammar. If no namespace is specified, the default
+namespace is assumed. If no package is specified, the default package is
+assumed.
+
+Examples:
+```javascript
+composer.action('hello')
+composer.action('myPackage/myAction')
+composer.action('/whisk.system/utils/echo')
+```
+The optional `options` dictionary makes it possible to provide a definition for
+the action being composed.
+```javascript
+// specify the code for the action as a function
+composer.action('hello', { action: function () { return { message: 'hello' } } })
+
+// specify the code for the action as a function reference
+function hello() {
+    return { message: 'hello' }
+}
+composer.action('hello', { action: hello })
+
+// specify the code for the action as a string
+composer.action('hello', { action: "const message = 'hello'; function main() { return { message } }" })
+
+
+// specify the code and runtime for the action
+composer.action('hello', {
+    action: {
+        kind: 'nodejs:8',
+        code: "function () { return { message: 'hello' } }"
+    }
+})
+
+// specify a file containing the code for the action
+composer.action('hello', { filename: 'hello.js' })
+
+// specify a sequence of actions
+composer.action('helloAndBye', { sequence: ['hello', 'bye'] })
+```
+The action may be defined by providing the code for the action as a string, as a
+Javascript function, or as a file name. Alternatively, a sequence action may be
+defined by providing the list of sequenced actions. The code (specified as a
+string) may be annotated with the kind of the action runtime.
+
+### Environment capture in actions
+
+Javascript functions used to define actions cannot capture any part of their
+declaration environment. The following code is not correct as the declaration of
+`name` would not be available at invocation time:
+```javascript
+let name = 'Dave'
+composer.action('hello', { action: function main() { return { message: 'Hello ' + name } } })
+```
+In contrast, the following code is correct as it resolves `name`'s value at
+composition time.
+```javascript
+let name = 'Dave'
+composer.action('hello', { action: `function main() { return { message: 'Hello ' + '${name}' } }` })
+```
+
+## Function
+
+`composer.function(fun)` is a composition with a single Javascript function
+_fun_. It applies the specified function to the input parameter object for the
+composition.
+ - If the function returns a value of type `function`, the composition returns
+   an error object.
+ - If the function throws an exception, the composition returns an error object.
+   The exception is logged as part of the conductor action invocation.
+ - If the function returns a value of type other than function, the value is
+   first converted to a JSON value using `JSON.stringify` followed by
+   `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON
+   value is then wrapped into a `{ value }` dictionary. The composition returns
+   the final JSON dictionary.
+ - If the function does not return a value and does not throw an exception, the
+   composition returns the input parameter object for the composition converted
+   to a JSON dictionary using `JSON.stringify` followed by `JSON.parse`.
+
+Examples:
+```javascript
+composer.function(params => ({ message: 'Hello ' + params.name }))
+composer.function(function () { return { error: 'error' } })
+
+function product({ x, y }) { return { product: x * y } }
+composer.function(product)
+```
+
+### Environment capture in functions
+
+Functions intended for compositions cannot capture any part of their declaration
+environment. They may however access and mutate variables in an environment
+consisting of the variables declared by the [let](#let) combinator discussed
+below.
+
+The following code is not correct:
+```javascript
+let name = 'Dave'
+composer.function(params => ({ message: 'Hello ' + name }))
+```
+The following code is correct:
+```javascript
+composer.let({ name: 'Dave' }, composer.function(params => ({ message: 'Hello ' + name })))
+```
+
+## Literal
+
+`composer.literal(value)` and its synonymous `composer.value(value)` output a
+constant JSON dictionary. This dictionary is obtained by first converting the
+_value_ argument to JSON using `JSON.stringify` followed by `JSON.parse`. If the
+resulting JSON value is not a JSON dictionary, the JSON value is then wrapped
+into a `{ value }` dictionary.
+
+The _value_ argument may be computed at composition time. For instance, the
+following composition captures the date at the time the composition is encoded
+to JSON:
+```javascript
+composer.sequence(
+    composer.literal(Date()),
+    composer.action('log', { action: params => ({ message: 'Composition time: ' + params.value }) }))
+```
+
+JSON values cannot represent functions. Applying `composer.literal` to a value
+of type `'function'` will result in an error. Functions embedded in a `value` of
+type `'object'`, e.g., `{ f: p => p, n: 42 }` will be silently omitted from the
+JSON dictionary. In other words, `composer.literal({ f: p => p, n: 42 })` will
+output `{ n: 42 }`.
+
+In general, a function can be embedded in a composition either by using the
+`composer.function` combinator, or by embedding the source code for the function
+as a string and later using `eval` to evaluate the function code.
+
+## Sequence
+
+`composer.sequence(composition_1, composition_2, ...)` or it synonymous
+`composer.seq(composition_1, composition_2, ...)` chain a series of compositions
+(possibly empty).
+
+The input parameter object for the composition is the input parameter object of
+the first composition in the sequence. The output parameter object of one
+composition in the sequence is the input parameter object for the next
+composition in the sequence. The output parameter object of the last composition
+in the sequence is the output parameter object for the composition.
+
+If one of the components fails (i.e., returns an error object), the remainder of
+the sequence is not executed. The output parameter object for the composition is
+the error object produced by the failed component.
+
+An empty sequence behaves as a sequence with a single function `params =>
+params`. The output parameter object for the empty sequence is its input
+parameter object unless it is an error object, in which case, as usual, the
+error object only contains the `error` field of the input parameter object.
+
+## Empty
+
+`composer.empty()` is a shorthand for the empty sequence `composer.sequence()`.
+It is typically used to make it clear that a composition, e.g., a branch of an
+`if` combinator, is intentionally doing nothing.
+
+## Task
+
+`composer.task(composition)` is equivalent to `composer.sequence(composition)`.
+
+## Let
+
+`composer.let({ name_1: value_1, name_2: value_2, ... }, composition_1,
+composition_2, ...)` declares one or more variables with the given names and
+initial values, and runs a sequence of compositions in the scope of these
+declarations.
+
+The initial values must be valid JSON values. In particular, `composer.let({foo:
+undefined }, composition)` is incorrect as `undefined` is not representable by a
+JSON value. Use `composer.let({ foo: null }, composition)` instead. For the same
+reason, initial values cannot be functions, e.g., `composer.let({ foo: params =>
+params }, composition)` is incorrect.
+
+Variables declared with `composer.let` may be accessed and mutated by functions
+__running__ as part of the following sequence (irrespective of their place of
+definition). In other words, name resolution is
+[dynamic](https://en.wikipedia.org/wiki/Name_resolution_(programming_languages)#Static_versus_dynamic).
+If a variable declaration is nested inside a declaration of a variable with the
+same name, the innermost declaration masks the earlier declarations.
+
+For example, the following composition invokes composition `composition`
+repeatedly `n` times.
+```javascript
+composer.let({ i: n }, composer.while(() => i-- > 0, composition))
+```
+Variables declared with `composer.let` are not visible to invoked actions.
+However, they may be passed as parameters to actions as for instance in:
+```javascript
+composer.let({ n: 42 }, () => ({ n }), 'increment', params => { n = params.n })
+```
+
+In this example, the variable `n` is exposed to the invoked action as a field of
+the input parameter object. Moreover, the value of the field `n` of the output
+parameter object is assigned back to variable `n`.
+
+## Mask
+
+`composer.mask(composition_1, composition_2, ...)` is meant to be used in
+combination with the `let` combinator. It runs a sequence of compositions
+excluding from their scope the variables declared by the innermost enclosing
+`let`. It is typically used to define composition templates that need to
+introduce variables.
+
+For instance, the following function is a possible implementation of a repeat
+loop:
+```javascript
+function loop(n, composition) {
+    return composer.let({ n }, composer.while(() => n-- > 0, composer.mask(composition)))
+}
+```
+This function takes two parameters: the number of iterations _n_ and the
+_composition_ to repeat _n_ times. Here, the `mask` combinator makes sure that
+this declaration of _n_ is not visible to _composition_. Thanks to `mask`, the
+following example correctly returns `{ value: 12 }`.
+```javascript
+composer.let({ n: 0 }, loop(3, loop(4, () => ++n)))
+```
+While composer variables are dynamically scoped, judicious use of the `mask`
+combinator can prevent incidental name collision.
+
+## If
+
+`composer.if(condition, consequent, [alternate])` runs either the _consequent_
+composition if the _condition_ evaluates to true or the _alternate_ composition
+if not.
+
+A _condition_ composition evaluates to true if and only if it produces a JSON
+dictionary with a field `value` with value `true`. Other fields are ignored.
+Because JSON values other than dictionaries are implicitly lifted to
+dictionaries with a `value` field, _condition_ may be a Javascript function
+returning a Boolean value. An expression such as `params.n > 0` is not a valid
+condition (or in general a valid composition). One should write instead `params
+=> params.n > 0`. The input parameter object for the composition is the input
+parameter object for the _condition_ composition.
+
+The _alternate_ composition may be omitted. If _condition_ fails, neither branch
+is executed.
+
+The output parameter object of the _condition_ composition is discarded, one the
+choice of a branch has been made and the _consequent_ composition or _alternate_
+composition is invoked on the input parameter object for the composition. For
+example, the following composition divides parameter `n` by two if `n` is even:
+```javascript
+composer.if(params => params.n % 2 === 0, params => { params.n /= 2 })
+```
+The `if_nosave` combinator is similar but it does not preserve the input
+parameter object, i.e., the _consequent_ composition or _alternate_ composition
+is invoked on the output parameter object of _condition_. The following example
+also divides parameter `n` by two if `n` is even:
+```javascript
+composer.if_nosave(params => { params.value = params.n % 2 === 0 }, params => { params.n /= 2 })
+```
+In the first example, the condition function simply returns a Boolean value. The
+consequent function uses the saved input parameter object to compute `n`'s
+value. In the second example, the condition function adds a `value` field to the
+input parameter object. The consequent function applies to the resulting object.
+In particular, in the second example, the output parameter object for the
+condition includes the `value` field.
+
+While, the `if` combinator is typically more convenient, preserving the input
+parameter object is not free as it counts toward the parameter size limit for
+OpenWhisk actions. In essence, the limit on the size of parameter objects
+processed during the evaluation of the condition is reduced by the size of the
+saved parameter object. The `if_nosave` combinator omits the parameter save,
+hence preserving the parameter size limit.
+
+## While
+
+`composer.while(condition, body)` runs _body_ repeatedly while _condition_
+evaluates to true. The _condition_ composition is evaluated before any execution
+of the _body_ composition. See
+[composer.if](#composerifcondition-consequent-alternate) for a discussion of
+conditions.
+
+A failure of _condition_ or _body_ interrupts the execution. The composition
+returns the error object from the failed component.
+
+The output parameter object of the _condition_ composition is discarded and the
+input parameter object for the _body_ composition is either the input parameter
+object for the whole composition the first time around or the output parameter
+object of the previous iteration of _body_. However, if `while_nosave`
+combinator is used, the input parameter object for _body_ is the output
+parameter object of _condition_. Moreover, the output parameter object for the
+whole composition is the output parameter object of the last _condition_
+evaluation.
+
+For instance, the following composition invoked on dictionary `{ n: 28 }`
+returns `{ n: 7 }`:
+```javascript
+composer.while(params => params.n % 2 === 0, params => { params.n /= 2 })
+```
+For instance, the following composition invoked on dictionary `{ n: 28 }`
+returns `{ n: 7, value: false }`:
+```javascript
+composer.while_nosave(params => { params.value = params.n % 2 === 0 }, params => { params.n /= 2 })
+```
+
+## Dowhile
+
+`composer.dowhile(condition, body)` is similar to `composer.while(body,
+condition)` except that _body_ is invoked before _condition_ is evaluated, hence
+_body_ is always invoked at least once.
+
+Like `while_nosave`, `dowhile_nosave` does not implicitly preserve the parameter
+object while evaluating _condition_.
+
+## Repeat
+
+`composer.repeat(count, composition_1, composition_2, ...)` invokes a sequence
+of compositions _count_ times.
+
+## Try
+
+`composer.try(body, handler)` runs _body_ with error handler _handler_.
+
+If _body_ returns an error object, _handler_ is invoked with this error object
+as its input parameter object. Otherwise, _handler_ is not run.
+
+## Finally
+
+`composer.finally(body, finalizer)` runs _body_ and then _finalizer_.
+
+The _finalizer_ is invoked in sequence after _body_ even if _body_ returns an
+error object. The output parameter object of _body_ (error object or not) is the
+input parameter object of _finalizer_.
+
+## Retry
+
+`composer.retry(count, composition_1, composition_2, ...)` runs a sequence of
+compositions retrying the sequence up to _count_ times if it fails. The output
+parameter object for the composition is either the output parameter object of
+the successful sequence invocation or the error object produced by the last
+sequence invocation.
+
+## Retain
+
+`composer.retain(composition_1, composition_2, ...)` runs a sequence of
+compositions on the input parameter object producing an object with two fields
+`params` and `result` such that `params` is the input parameter object of the
+composition and `result` is the output parameter object of the sequence.
+
+If the sequence fails, the output of the `retain` combinator is only the error
+object (i.e., the input parameter object is not preserved). In contrast, the
+`retain_catch` combinator always outputs `{ params, result }`, even if `result`
+is an error object.
+
+## Merge
+
+`composer.merge(composition_1, composition_2, ...)` runs a sequence of
+compositions on the input parameter object and merge the output parameter object
+of the sequence into the input parameter object. In other words,
+`composer.merge(composition_1, composition_2, ...)` is a shorthand for:
+```
+composer.seq(composer.retain(composition_1, composition_2, ...), ({ params, result }) => Object.assign(params, result))
+```
+
+## Async
+
+`composer.async(composition_1, composition_2, ...)` runs a sequence of
+compositions asynchronously. It invokes the sequence but does not wait for it to
+execute. It immediately returns a dictionary that includes a field named
+`activationId` with the activation id for the sequence invocation.
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
new file mode 100644
index 0000000..dabe630
--- /dev/null
+++ b/docs/COMMANDS.md
@@ -0,0 +1,118 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# Commands
+
+The `compose` command compiles composition code to a portable JSON format. The
+`deploy` command deploys JSON-encoded compositions. These commands are intended
+as a minimal complement to the OpenWhisk CLI. The OpenWhisk CLI already has the
+capability to configure, invoke, and delete compositions since these are just
+OpenWhisk actions but lacks the capability to create composition actions. The
+`compose` and `deploy` commands bridge this gap. They make it possible to deploy
+compositions as part of the development cycle or in shell scripts. They do not
+replace the OpenWhisk CLI however as they do not duplicate existing OpenWhisk
+CLI capabilities.
+
+## Compose
+
+```
+compose
+```
+```
+Usage:
+  compose composition.js [flags]
+Flags:
+  --ast                  only output the ast for the composition
+  -v, --version          output the composer version
+```
+The `compose` command takes a Javascript module that exports a composition
+object (for example [demo.js](../samples/demo.js)) and compiles this object to a
+portable JSON format on the standard output.
+```
+compose demo.js > demo.json
+```
+If the `--ast` option is specified, the `compose` command only outputs a JSON
+representation of the Abstract Syntax Tree for the composition.
+
+# Deploy
+
+```
+deploy
+```
+```
+Usage:
+  deploy composition composition.json [flags]
+Flags:
+  -a, --annotation KEY=VALUE        add KEY annotation with VALUE
+  -A, --annotation-file KEY=FILE    add KEY annotation with FILE content
+  --apihost HOST                    API HOST
+  -i, --insecure                    bypass certificate checking
+  -u, --auth KEY                    authorization KEY
+  -v, --version                     output the composer version
+  -w, --overwrite                   overwrite actions if already defined
+```
+The `deploy` command deploys a JSON-encoded composition with the given name.
+```
+deploy demo demo.json -w
+```
+```
+ok: created /_/authenticate,/_/success,/_/failure,/_/demo
+```
+
+The `deploy` command synthesizes and deploys a conductor action that implements
+the composition with the given name. It also deploys the composed actions for
+which definitions are provided as part of the composition.
+
+The `deploy` command outputs the list of deployed actions or an error result. If
+an error occurs during deployment, the state of the various actions is unknown.
+
+The `-w` option authorizes the `deploy` command to overwrite existing
+definitions. More precisely, it deletes the deployed actions before recreating
+them. As a result, default parameters, limits, and annotations on preexisting
+actions are lost.
+
+### Annotations
+
+The `deploy` command implicitly annotates the deployed composition action with
+the required `conductor` annotations. Other annotations may be specified by
+means of the flags:
+```
+  -a, --annotation KEY=VALUE        add KEY annotation with VALUE
+  -A, --annotation-file KEY=FILE    add KEY annotation with FILE content
+```
+
+### OpenWhisk instance
+
+Like the OpenWhisk CLI, the `deploy` command supports the following flags for
+specifying the OpenWhisk instance to use:
+```
+  --apihost HOST                    API HOST
+  -i, --insecure                    bypass certificate checking
+  -u, --auth KEY                    authorization KEY
+```
+If the `--apihost` flag is absent, the environment variable `__OW_API_HOST` is
+used in its place. If neither is available, the `deploy` command extracts the
+`APIHOST` key from the whisk property file for the current user.
+
+If the `--auth` flag is absent, the environment variable `__OW_API_KEY` is used
+in its place. If neither is available, the `deploy` command extracts the `AUTH`
+key from the whisk property file for the current user.
+
+The default path for the whisk property file is `$HOME/.wskprops`. It can be
+altered by setting the `WSK_CONFIG_FILE` environment variable.
diff --git a/docs/COMPOSITIONS.md b/docs/COMPOSITIONS.md
new file mode 100644
index 0000000..8a19717
--- /dev/null
+++ b/docs/COMPOSITIONS.md
@@ -0,0 +1,127 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# Compositions
+
+Composer makes it possible to assemble actions into rich workflows called
+_compositions_. An example composition is described in
+[../README.md](../README.md).
+
+## Control flow
+
+Compositions can express the control flow of typical a sequential imperative
+programming language: sequences, conditionals, loops, structured error handling.
+This control flow is specified using _combinator_ methods such as:
+- `composer.sequence(firstAction, secondAction)`
+- `composer.if(conditionAction, consequentAction, alternateAction)`
+- `composer.try(bodyAction, handlerAction)`
+
+Combinators are described in [COMBINATORS.md](COMBINATORS.md).
+
+## Composition objects
+
+Combinators return composition objects, i.e., instances of the `Composition`
+class.
+
+## Parameter objects and error objects
+
+A composition, like any action, accepts a JSON dictionary (the _input parameter
+object_) and produces a JSON dictionary (the _output parameter object_). An
+output parameter object with an `error` field is an _error object_. A
+composition _fails_ if it produces an error object.
+
+By convention, an error object returned by a composition is stripped from all
+fields except from the `error` field. This behavior is consistent with the
+OpenWhisk action semantics, e.g., the action with code `function main() { return
+{ error: 'KO', message: 'OK' } }` outputs `{ error: 'KO' }`.
+
+Error objects play a specific role as they interrupt the normal flow of
+execution, akin to exceptions in traditional programming languages. For
+instance, if a component of a sequence returns an error object, the remainder of
+the sequence is not executed. Moreover, if the sequence is enclosed in an error
+handling composition like a `composer.try(sequence, handler)` combinator, the
+execution continues with the error handler.
+
+## Data flow
+
+The invocation of a composition triggers a series of computations (possibly
+empty, e.g., for the empty sequence) obtained by chaining the components of the
+composition along the path of execution. The input parameter object for the
+composition is the input parameter object of the first component in the chain.
+The output parameter object of a component in the chain is typically the input
+parameter object for the next component if any or the output parameter object
+for the composition if this is the final component in the chain.
+
+For example, the composition `composer.sequence('triple', 'increment')` invokes
+the `increment` action on the output of the `triple` action.
+
+Some combinators however are designed to alter the default flow of data. For
+instance, the `composer.merge('myAction')` composition merges the input and
+output parameter objects of `myAction`.
+
+## Components
+
+Components of a compositions can be actions, Javascript functions, or
+compositions.
+
+Javascript functions can be viewed as simple, anonymous actions that do not need
+to be deployed and managed separately from the composition they belong to.
+Functions are typically used to alter a parameter object between two actions
+that expect different schemas, as in:
+```javascript
+composer.sequence('getUserNameAndPassword', params => ({ key = btoa(params.user + ':' + params.password) }), 'authenticate')
+```
+Combinators can be nested, e.g.,
+```javascript
+composer.if('isEven', 'half', composer.sequence('triple', 'increment'))
+```
+Compositions can reference other compositions by name. For instance, assuming we
+deploy the sequential composition of the `triple` and `increment` actions as the
+composition `tripleAndIncrement`, the following code behaves identically to the
+previous example:
+```javascript
+composer.if('isEven', 'half', 'tripleAndIncrement')
+```
+The behavior of this last composition would be altered if we redefine the
+`tripleAndIncrement` composition to do something else, whereas the first example
+would not be affected.
+
+## Embedded action definitions
+
+A composition can embed the definitions of none, some, or all the composed
+actions as illustrated in [demo.js](../samples/demo.js):
+```javascript
+composer.if(
+    composer.action('authenticate', { action: function ({ password }) { return { value: password === 'abc123' } } }),
+    composer.action('success', { action: function () { return { message: 'success' } } }),
+    composer.action('failure', { action: function () { return { message: 'failure' } } }))
+)
+```
+Deploying such a composition deploys the embedded actions.
+
+## Conductor actions
+
+Compositions are implemented by means of OpenWhisk [conductor
+actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md).
+Compositions have all the attributes and capabilities of an action, e.g.,
+default parameters, limits, blocking invocation, web export. Execution
+[traces](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md#activations)
+and
+[limits](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md#limits)
+of compositions follow from conductor actions.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..1abced5
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,30 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# Composer Package
+
+The Composer package consists of:
+* the [composer](../composer.js) Node.js module for authoring compositions,
+* the [compose](../bin/compose.js) and [deploy](../bin/deploy.js) commands for
+  managing compositions from the command line.
+
+The documentation for the Composer package is organized as follows:
+- [COMPOSITIONS.md](COMPOSITIONS.md) gives a brief introduction to compositions.
+- [COMBINATORS.md](COMBINATORS.md) explains the composition constructs.
+- [COMMANDS.md](COMMANDS.md) describes the `compose` and `deploy` commands.
diff --git a/fqn.js b/fqn.js
new file mode 100644
index 0000000..44836f7
--- /dev/null
+++ b/fqn.js
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict'
+
+/**
+ * Parses a (possibly fully qualified) entity name and validates it.
+ * If it's not a fully qualified name, then attempts to qualify it.
+ *
+ * Examples:
+ *   foo => /_/foo
+ *   pkg/foo => /_/pkg/foo
+ *   /ns/foo => /ns/foo
+ *   /ns/pkg/foo => /ns/pkg/foo
+ */
+module.exports = function (name) {
+  if (typeof name !== 'string') throw new Error('Name must be a string')
+  if (name.trim().length === 0) throw new Error('Name is not valid')
+  name = name.trim()
+  const delimiter = '/'
+  const parts = name.split(delimiter)
+  const n = parts.length
+  const leadingSlash = name[0] === delimiter
+  // no more than /ns/p/a
+  if (n < 1 || n > 4 || (leadingSlash && n === 2) || (!leadingSlash && n === 4)) throw new Error('Name is not valid')
+  // skip leading slash, all parts must be non empty (could tighten this check to match EntityName regex)
+  parts.forEach(function (part, i) { if (i > 0 && part.trim().length === 0) throw new Error('Name is not valid') })
+  const newName = parts.join(delimiter)
+  if (leadingSlash) return newName
+  else if (n < 3) return `${delimiter}_${delimiter}${newName}`
+  else return `${delimiter}${newName}`
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a9710a4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,61 @@
+{
+  "name": "@ibm-functions/composer",
+  "version": "0.8.1",
+  "description": "Composer is a new programming model for composing cloud functions built on Apache OpenWhisk.",
+  "homepage": "https://github.com/ibm-functions/composer",
+  "main": "composer.js",
+  "scripts": {
+    "test": "standard && mocha"
+  },
+  "bin": {
+    "compose": "./bin/compose.js",
+    "deploy": "./bin/deploy.js"
+  },
+  "files": [
+    "bin/",
+    "composer.js",
+    "conductor.js",
+    "fqn.js",
+    "docs/*.md",
+    "samples/"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/ibm-functions/composer.git"
+  },
+  "keywords": [
+    "functions",
+    "serverless",
+    "composer",
+    "openwhisk"
+  ],
+  "dependencies": {
+    "minimist": "^1.2.0",
+    "openwhisk": "^3.11.0",
+    "terser": "^3.8.2"
+  },
+  "devDependencies": {
+    "mocha": "^5.2.0",
+    "pre-commit": "^1.2.2",
+    "standard": "^12.0.1"
+  },
+  "author": {
+    "name": "Olivier Tardieu",
+    "email": "tardieu@us.ibm.com"
+  },
+  "contributors": [
+    {
+      "name": "Kerry Chang",
+      "email": "kerry.chang@ibm.com"
+    },
+    {
+      "name": "Rodric Rabbah",
+      "email": "rabbah@us.ibm.com"
+    },
+    {
+      "name": "Nick Mitchell",
+      "email": "nickm@us.ibm.com"
+    }
+  ],
+  "license": "Apache-2.0"
+}
diff --git a/samples/demo.js b/samples/demo.js
new file mode 100644
index 0000000..12c632f
--- /dev/null
+++ b/samples/demo.js
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const composer = require('@ibm-functions/composer')
+
+module.exports = composer.if(
+  composer.action('authenticate', { action: function ({ password }) { return { value: password === 'abc123' } } }),
+  composer.action('success', { action: function () { return { message: 'success' } } }),
+  composer.action('failure', { action: function () { return { message: 'failure' } } }))
diff --git a/samples/demo.json b/samples/demo.json
new file mode 100644
index 0000000..d02055d
--- /dev/null
+++ b/samples/demo.json
@@ -0,0 +1,143 @@
+{
+    "composition": {
+        "type": "let",
+        "declarations": {
+            "params": null
+        },
+        "components": [
+            {
+                "type": "finally",
+                "body": {
+                    "type": "function",
+                    "function": {
+                        "exec": {
+                            "kind": "nodejs:default",
+                            "code": "args => { params = args }"
+                        }
+                    }
+                },
+                "finalizer": {
+                    "type": "if_nosave",
+                    "test": {
+                        "type": "mask",
+                        "components": [
+                            {
+                                "type": "action",
+                                "name": "/_/authenticate",
+                                "path": ".test"
+                            }
+                        ]
+                    },
+                    "consequent": {
+                        "type": "finally",
+                        "body": {
+                            "type": "function",
+                            "function": {
+                                "exec": {
+                                    "kind": "nodejs:default",
+                                    "code": "() => params"
+                                }
+                            }
+                        },
+                        "finalizer": {
+                            "type": "mask",
+                            "components": [
+                                {
+                                    "type": "action",
+                                    "name": "/_/success",
+                                    "path": ".consequent"
+                                }
+                            ]
+                        }
+                    },
+                    "alternate": {
+                        "type": "finally",
+                        "body": {
+                            "type": "function",
+                            "function": {
+                                "exec": {
+                                    "kind": "nodejs:default",
+                                    "code": "() => params"
+                                }
+                            }
+                        },
+                        "finalizer": {
+                            "type": "mask",
+                            "components": [
+                                {
+                                    "type": "action",
+                                    "name": "/_/failure",
+                                    "path": ".alternate"
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        ],
+        "path": ""
+    },
+    "ast": {
+        "type": "if",
+        "test": {
+            "type": "action",
+            "name": "/_/authenticate",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function ({ password }) { return { value: password === 'abc123' } }"
+                }
+            }
+        },
+        "consequent": {
+            "type": "action",
+            "name": "/_/success",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function () { return { message: 'success' } }"
+                }
+            }
+        },
+        "alternate": {
+            "type": "action",
+            "name": "/_/failure",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function () { return { message: 'failure' } }"
+                }
+            }
+        }
+    },
+    "version": "0.8.0",
+    "actions": [
+        {
+            "name": "/_/authenticate",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function ({ password }) { return { value: password === 'abc123' } }"
+                }
+            }
+        },
+        {
+            "name": "/_/success",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function () { return { message: 'success' } }"
+                }
+            }
+        },
+        {
+            "name": "/_/failure",
+            "action": {
+                "exec": {
+                    "kind": "nodejs:default",
+                    "code": "const main = function () { return { message: 'failure' } }"
+                }
+            }
+        }
+    ]
+}
diff --git a/test/composer.js b/test/composer.js
new file mode 100644
index 0000000..eca428d
--- /dev/null
+++ b/test/composer.js
@@ -0,0 +1,417 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* eslint-env mocha */
+
+'use strict'
+
+const assert = require('assert')
+const composer = require('../composer')
+
+function check (combinator, n, p, name) {
+  if (n === undefined) {
+    it('variable argument count', function () {
+      for (let i = 0; i < 5; i++) composer[combinator](...Array(i).fill('foo'))
+      for (let i = 0; i < 5; i++) composer[combinator](...Array(i).fill(() => { }))
+    })
+  } else {
+    it('argument count', function () {
+      for (let i = n; i <= (p || n); i++) composer[combinator](...Array(i).fill('foo'))
+    })
+    it('too many arguments', function () {
+      try {
+        composer[combinator](...Array((p || n) + 1).fill('foo'))
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Too many arguments'))
+      }
+    })
+    if (n > 0) {
+      it('too few arguments', function () {
+        try {
+          composer[combinator](...Array(n - 1).fill('foo'))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Invalid argument'))
+        }
+      })
+    }
+  }
+  it('combinator type', function () {
+    assert.ok(composer[combinator](...Array(n || 0).fill('foo')).type === name || combinator)
+  })
+}
+
+describe('composer', function () {
+  describe('composer.action', function () {
+    it('argument count', function () {
+      composer.action('foo')
+    })
+
+    it('too many arguments', function () {
+      try {
+        composer.action('foo', {}, 'foo')
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Too many arguments'))
+      }
+    })
+
+    it('too few arguments', function () {
+      try {
+        composer.action()
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Name must be a string'))
+      }
+    })
+
+    it('combinator type', function () {
+      assert.ok(composer.action('foo').type === 'action')
+    })
+
+    it('valid and invalid names', function () {
+      let combos = [
+        { n: 42, s: false, e: 'Name must be a string' },
+        { n: '', s: false, e: 'Name is not valid' },
+        { n: ' ', s: false, e: 'Name is not valid' },
+        { n: '/', s: false, e: 'Name is not valid' },
+        { n: '//', s: false, e: 'Name is not valid' },
+        { n: '/a', s: false, e: 'Name is not valid' },
+        { n: '/a/b/c/d', s: false, e: 'Name is not valid' },
+        { n: '/a/b/c/d/', s: false, e: 'Name is not valid' },
+        { n: 'a/b/c/d', s: false, e: 'Name is not valid' },
+        { n: '/a/ /b', s: false, e: 'Name is not valid' },
+        { n: 'a', e: false, s: '/_/a' },
+        { n: 'a/b', e: false, s: '/_/a/b' },
+        { n: 'a/b/c', e: false, s: '/a/b/c' },
+        { n: '/a/b', e: false, s: '/a/b' },
+        { n: '/a/b/c', e: false, s: '/a/b/c' }
+      ]
+      combos.forEach(({ n, s, e }) => {
+        if (s) {
+          // good cases
+          assert.ok(composer.action(n).name, s)
+        } else {
+          // error cases
+          try {
+            composer.action(n)
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith(e))
+          }
+        }
+      })
+    })
+
+    it('valid and invalid options', function () {
+      composer.action('foo', {})
+      try {
+        composer.action('foo', 42)
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+  })
+
+  describe('composer.function', function () {
+    check('function', 1)
+
+    it('function', function () {
+      composer.function(() => { })
+    })
+
+    it('string', function () {
+      composer.function('() => {}')
+    })
+
+    it('number (invalid)', function () {
+      try {
+        composer.function(42)
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+  })
+
+  describe('composer.literal', function () {
+    check('literal', 1)
+
+    it('boolean', function () {
+      composer.literal(true)
+    })
+
+    it('number', function () {
+      composer.literal(42)
+    })
+
+    it('string', function () {
+      composer.literal('foo')
+    })
+
+    it('dictionary', function () {
+      composer.literal({ foo: 42 })
+    })
+
+    it('function (invalid)', function () {
+      try {
+        composer.literal(() => { })
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+  })
+
+  describe('composer.value', function () {
+    check('value', 1)
+
+    it('boolean', function () {
+      composer.value(true)
+    })
+
+    it('number', function () {
+      composer.value(42)
+    })
+
+    it('string', function () {
+      composer.value('foo')
+    })
+
+    it('dictionary', function () {
+      composer.value({ foo: 42 })
+    })
+
+    it('function (invalid)', function () {
+      try {
+        composer.value(() => { })
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+  })
+
+  describe('composer.parse', function () {
+    it('argument count', function () {
+      composer.parse({ 'type': 'sequence', 'components': [] })
+    })
+
+    it('too many arguments', function () {
+      try {
+        composer.parse({ 'type': 'sequence', 'components': [] }, 'foo')
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Too many arguments'))
+      }
+    })
+
+    it('too few arguments', function () {
+      try {
+        composer.parse()
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('combinator type', function () {
+      assert.ok(composer.parse({
+        'type': 'sequence',
+        'components': [{
+          'type': 'action',
+          'name': 'echo'
+        }, {
+          'type': 'action',
+          'name': 'echo'
+        }]
+      }).type === 'sequence')
+    })
+  })
+
+  describe('composer.task', function () {
+    check('task', 1, 1, 'action')
+
+    it('string', function () {
+      composer.task('isNotOne')
+    })
+
+    it('function', function () {
+      composer.task(() => { })
+    })
+
+    it('null', function () {
+      composer.task(null)
+    })
+
+    it('boolean (invalid)', function () {
+      try {
+        composer.task(false)
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('number (invalid)', function () {
+      try {
+        composer.task(42)
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('dictionary (invalid)', function () {
+      try {
+        composer.task({ foo: 42 })
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+  })
+
+  describe('composer.let', function () {
+    it('variable argument count', function () {
+      composer.let({})
+      composer.let({}, 'foo')
+      composer.let({}, 'foo', 'foo')
+    })
+
+    it('too few arguments', function () {
+      try {
+        composer.let()
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('combinator type', function () {
+      assert.ok(composer.let({}).type === 'let')
+    })
+  })
+
+  describe('composer.repeat', function () {
+    it('variable argument count', function () {
+      composer.repeat(42)
+      composer.repeat(42, 'foo')
+      composer.repeat(42, 'foo', 'foo')
+    })
+
+    it('too few arguments', function () {
+      try {
+        composer.repeat()
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('combinator type', function () {
+      assert.ok(composer.repeat(42).type === 'repeat')
+    })
+  })
+
+  describe('composer.retry', function () {
+    it('variable argument count', function () {
+      composer.retry(42)
+      composer.retry(42, 'foo')
+      composer.retry(42, 'foo', 'foo')
+    })
+
+    it('too few arguments', function () {
+      try {
+        composer.retry()
+        assert.fail()
+      } catch (error) {
+        assert.ok(error.message.startsWith('Invalid argument'))
+      }
+    })
+
+    it('combinator type', function () {
+      assert.ok(composer.retry(42).type === 'retry')
+    })
+  })
+
+  describe('composer.if', function () {
+    check('if', 2, 3)
+  })
+
+  describe('composer.if_nosave', function () {
+    check('if_nosave', 2, 3)
+  })
+
+  describe('composer.while', function () {
+    check('while', 2)
+  })
+
+  describe('composer.while_nosave', function () {
+    check('while_nosave', 2)
+  })
+
+  describe('composer.dowhile', function () {
+    check('dowhile', 2)
+  })
+
+  describe('composer.dowhile_nosave', function () {
+    check('dowhile_nosave', 2)
+  })
+
+  describe('composer.try', function () {
+    check('try', 2)
+  })
+
+  describe('composer.finally', function () {
+    check('finally', 2)
+  })
+
+  describe('composer.empty', function () {
+    check('empty', 0)
+  })
+
+  describe('composer.mask', function () {
+    check('mask')
+  })
+
+  describe('composer.async', function () {
+    check('async')
+  })
+
+  describe('composer.retain', function () {
+    check('retain')
+  })
+
+  describe('composer.retain_catch', function () {
+    check('retain_catch')
+  })
+
+  describe('composer.sequence', function () {
+    check('sequence')
+  })
+
+  describe('composer.seq', function () {
+    check('seq')
+  })
+
+  describe('composer.merge', function () {
+    check('merge')
+  })
+})
diff --git a/test/conductor.js b/test/conductor.js
new file mode 100644
index 0000000..da3324d
--- /dev/null
+++ b/test/conductor.js
@@ -0,0 +1,592 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* eslint-env mocha */
+
+'use strict'
+
+const assert = require('assert')
+const composer = require('../composer')
+const conductor = require('../conductor')
+const name = 'TestAction'
+const wsk = conductor({ ignore_certs: process.env.IGNORE_CERTS && process.env.IGNORE_CERTS !== 'false' && process.env.IGNORE_CERTS !== '0' })
+
+// deploy action
+const define = action => wsk.actions.delete(action.name).catch(() => { }).then(() => wsk.actions.create(action))
+
+// deploy and invoke composition
+const invoke = (composition, params = {}, blocking = true) => wsk.compositions.deploy(Object.assign({ name }, composition.compile()), true)
+  .then(() => wsk.actions.invoke({ name, params, blocking }))
+  .then(activation => activation.response.success ? activation : Promise.reject(Object.assign(new Error(), { error: activation })))
+
+describe('composer', function () {
+  let n, x, y // dummy variables
+
+  this.timeout(60000)
+
+  before('deploy test actions', function () {
+    return define({ name: 'echo', action: 'const main = x=>x' })
+      .then(() => define({ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' }))
+      .then(() => define({ name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }))
+      .then(() => define({ name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' }))
+      .then(() => define({ name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' }))
+      .then(() => wsk.compositions.deploy(Object.assign({ name: '_DivideByTwo' }, composer.seq('DivideByTwo').compile()), true))
+  })
+
+  describe('blocking invocations', function () {
+    describe('actions', function () {
+      it('action must return true', function () {
+        return invoke(composer.action('isNotOne'), { n: 0 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+      })
+
+      it('action must return false', function () {
+        return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: false }))
+      })
+
+      it('action must return activationId', function () {
+        return invoke(composer.async('isNotOne'), { n: 1 }).then(activation => assert.ok(activation.response.result.activationId))
+      })
+
+      it('action name must parse to fully qualified', function () {
+        let combos = [
+          { n: 42, s: false, e: 'Name must be a string' },
+          { n: '', s: false, e: 'Name is not valid' },
+          { n: ' ', s: false, e: 'Name is not valid' },
+          { n: '/', s: false, e: 'Name is not valid' },
+          { n: '//', s: false, e: 'Name is not valid' },
+          { n: '/a', s: false, e: 'Name is not valid' },
+          { n: '/a/b/c/d', s: false, e: 'Name is not valid' },
+          { n: '/a/b/c/d/', s: false, e: 'Name is not valid' },
+          { n: 'a/b/c/d', s: false, e: 'Name is not valid' },
+          { n: '/a/ /b', s: false, e: 'Name is not valid' },
+          { n: 'a', e: false, s: '/_/a' },
+          { n: 'a/b', e: false, s: '/_/a/b' },
+          { n: 'a/b/c', e: false, s: '/a/b/c' },
+          { n: '/a/b', e: false, s: '/a/b' },
+          { n: '/a/b/c', e: false, s: '/a/b/c' }
+        ]
+        combos.forEach(({ n, s, e }) => {
+          if (s) {
+            // good cases
+            assert.ok(composer.action(n).name, s)
+          } else {
+            // error cases
+            try {
+              composer.action(n)
+              assert.fail()
+            } catch (error) {
+              assert.ok(error.message.startsWith(e))
+            }
+          }
+        })
+      })
+
+      it('invalid options', function () {
+        try {
+          invoke(composer.action('foo', 42))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Invalid argument'))
+        }
+      })
+
+      it('too many arguments', function () {
+        try {
+          invoke(composer.action('foo', {}, 'foo'))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Too many arguments'))
+        }
+      })
+    })
+
+    describe('literals', function () {
+      it('true', function () {
+        return invoke(composer.literal(true)).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+      })
+
+      it('42', function () {
+        return invoke(composer.literal(42)).then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+      })
+
+      it('invalid argument', function () {
+        try {
+          invoke(composer.literal(invoke))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Invalid argument'))
+        }
+      })
+
+      it('too many arguments', function () {
+        try {
+          invoke(composer.literal('foo', 'foo'))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Too many arguments'))
+        }
+      })
+    })
+
+    describe('functions', function () {
+      it('function must return true', function () {
+        return invoke(composer.function(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+      })
+
+      it('function must return false', function () {
+        return invoke(composer.function(function ({ n }) { return n % 2 === 0 }), { n: 3 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: false }))
+      })
+
+      it('function must fail', function () {
+        return invoke(composer.function(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error))
+      })
+
+      it('function must throw', function () {
+        return invoke(composer.function(() => ({ error: 'foo', n: 42 }))).then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' }))
+      })
+
+      it('function must mutate params', function () {
+        return invoke(composer.function(params => { params.foo = 'foo' }), { n: 42 }).then(activation => assert.deepStrictEqual(activation.response.result, { foo: 'foo', n: 42 }))
+      })
+
+      it('function as string', function () {
+        return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+      })
+
+      it('function may return a promise', function () {
+        return invoke(composer.function(({ n }) => Promise.resolve(n % 2 === 0)), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+      })
+
+      it('invalid argument', function () {
+        try {
+          invoke(composer.function(42))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Invalid argument'))
+        }
+      })
+
+      it('too many arguments', function () {
+        try {
+          invoke(composer.function(() => n, () => { }))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Too many arguments'))
+        }
+      })
+    })
+
+    describe('deserialize', function () {
+      it('should deserialize a serialized composition', function () {
+        const json = {
+          'type': 'sequence',
+          'components': [{
+            'type': 'action',
+            'name': 'echo'
+          }, {
+            'type': 'action',
+            'name': 'echo'
+          }]
+        }
+        return invoke(composer.parse(json), { message: 'hi' }).then(activation => assert.deepStrictEqual(activation.response.result, { message: 'hi' }))
+      })
+    })
+
+    describe('tasks', function () {
+      describe('action tasks', function () {
+        it('action must return true', function () {
+          return invoke(composer.task('isNotOne'), { n: 0 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+        })
+      })
+
+      describe('function tasks', function () {
+        it('function must return true', function () {
+          return invoke(composer.task(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+        })
+      })
+
+      describe('null task', function () {
+        it('null task must return input', function () {
+          return invoke(composer.task(null), { foo: 'foo' }).then(activation => assert.deepStrictEqual(activation.response.result, { foo: 'foo' }))
+        })
+
+        it('null task must fail on error input', function () {
+          return invoke(composer.task(null), { error: 'foo' }).then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' }))
+        })
+      })
+
+      describe('invalid tasks', function () {
+        it('a Boolean is not a valid task', function () {
+          try {
+            invoke(composer.task(false))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+
+        it('a number is not a valid task', function () {
+          try {
+            invoke(composer.task(42))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+
+        it('a dictionary is not a valid task', function () {
+          try {
+            invoke(composer.task({ foo: 'foo' }))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+      })
+
+      it('too many arguments', function () {
+        try {
+          invoke(composer.task('foo', 'foo'))
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith('Too many arguments'))
+        }
+      })
+    })
+
+    describe('combinators', function () {
+      describe('sequence', function () {
+        it('flat', function () {
+          return invoke(composer.sequence('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 }))
+        })
+
+        it('nested right', function () {
+          return invoke(composer.sequence('TripleAndIncrement', composer.sequence('DivideByTwo', 'DivideByTwo')), { n: 5 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 }))
+        })
+
+        it('nested left', function () {
+          return invoke(composer.sequence(composer.sequence('TripleAndIncrement', 'DivideByTwo'), 'DivideByTwo'), { n: 5 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 }))
+        })
+
+        it('seq', function () {
+          return invoke(composer.seq('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 }))
+        })
+      })
+
+      describe('if', function () {
+        it('condition = true', function () {
+          return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 2 }))
+        })
+
+        it('condition = false', function () {
+          return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 10 }))
+        })
+
+        it('condition = true, then branch only', function () {
+          return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 2 }))
+        })
+
+        it('condition = false, then branch only', function () {
+          return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 3 }))
+        })
+
+        it('condition = true, nosave option', function () {
+          return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 2 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: true, then: true }))
+        })
+
+        it('condition = false, nosave option', function () {
+          return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, else: true }))
+        })
+
+        it('too many arguments', function () {
+          try {
+            invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement'))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Too many arguments'))
+          }
+        })
+      })
+
+      describe('while', function () {
+        it('a few iterations', function () {
+          return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 }))
+        })
+
+        it('no iteration', function () {
+          return invoke(composer.while(() => false, ({ n }) => ({ n: n - 1 })), { n: 1 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 }))
+        })
+
+        it('nosave option', function () {
+          return invoke(composer.while_nosave(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, n: 1 }))
+        })
+
+        it('too many arguments', function () {
+          try {
+            invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 })
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Too many arguments'))
+          }
+        })
+      })
+
+      describe('dowhile', function () {
+        it('a few iterations', function () {
+          return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne'), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 }))
+        })
+
+        it('one iteration', function () {
+          return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), () => false), { n: 1 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 0 }))
+        })
+
+        it('nosave option', function () {
+          return invoke(composer.dowhile_nosave(({ n }) => ({ n: n - 1 }), ({ n }) => ({ n, value: n !== 1 })), { n: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, n: 1 }))
+        })
+
+        it('too many arguments', function () {
+          try {
+            invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 })
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Too many arguments'))
+          }
+        })
+      })
+
+      describe('try', function () {
+        it('no error', function () {
+          return invoke(composer.try(() => true, error => ({ message: error.error })))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: true }))
+        })
+
+        it('error', function () {
+          return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error })))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' }))
+        })
+
+        it('try must throw', function () {
+          return invoke(composer.try(composer.task(null), error => ({ message: error.error })), { error: 'foo' })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' }))
+        })
+
+        it('while must throw', function () {
+          return invoke(composer.try(composer.while(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' }))
+        })
+
+        it('if must throw', function () {
+          return invoke(composer.try(composer.if(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' }))
+        })
+
+        it('retain', function () {
+          return invoke(composer.retain(composer.try(() => ({ p: 4 }), null)), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { p: 4 } }))
+        })
+
+        it('too many arguments', function () {
+          try {
+            invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne'))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Too many arguments'))
+          }
+        })
+      })
+
+      describe('finally', function () {
+        it('no error', function () {
+          return invoke(composer.finally(() => true, params => ({ params })))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { params: { value: true } }))
+        })
+
+        it('error', function () {
+          return invoke(composer.finally(() => ({ error: 'foo' }), params => ({ params })))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { params: { error: 'foo' } }))
+        })
+
+        it('too many arguments', function () {
+          try {
+            invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne'))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Too many arguments'))
+          }
+        })
+      })
+
+      describe('let', function () {
+        it('one variable', function () {
+          return invoke(composer.let({ x: 42 }, () => x))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+
+        it('masking', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x)))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 }))
+        })
+
+        it('two variables', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ y: 69 }, () => x + y)))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 }))
+        })
+
+        it('two variables combined', function () {
+          return invoke(composer.let({ x: 42, y: 69 }, () => x + y))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 }))
+        })
+
+        it('scoping', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x), ({ value }) => value + x))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 }))
+        })
+
+        it('invalid argument', function () {
+          try {
+            invoke(composer.let(invoke))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+      })
+
+      describe('mask', function () {
+        it('let/let/mask', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, composer.mask(() => x))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+
+        it('let/mask/let', function () {
+          return invoke(composer.let({ x: 42 }, composer.mask(composer.let({ x: 69 }, () => x))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 }))
+        })
+
+        it('let/let/try/mask', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 },
+            composer.try(composer.mask(() => x), () => { }))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+
+        it('let/let/let/mask', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 },
+            composer.let({ x: -1 }, composer.mask(() => x)))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 }))
+        })
+
+        it('let/let/let/mask/mask', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 },
+            composer.let({ x: -1 }, composer.mask(composer.mask(() => x))))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+
+        it('let/let/mask/let/mask', function () {
+          return invoke(composer.let({ x: 42 }, composer.let({ x: 69 },
+            composer.mask(composer.let({ x: -1 }, composer.mask(() => x))))))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+      })
+
+      describe('retain', function () {
+        it('base case', function () {
+          return invoke(composer.retain('TripleAndIncrement'), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } }))
+        })
+
+        it('throw error', function () {
+          return invoke(composer.retain(() => ({ error: 'foo' })), { n: 3 })
+            .then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' }))
+        })
+
+        it('catch error', function () {
+          return invoke(composer.retain_catch(() => ({ error: 'foo' })), { n: 3 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { error: 'foo' } }))
+        })
+      })
+
+      describe('merge', function () {
+        it('base case', function () {
+          return invoke(composer.merge('TripleAndIncrement'), { n: 3, p: 4 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 10, p: 4 }))
+        })
+      })
+
+      describe('repeat', function () {
+        it('a few iterations', function () {
+          return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 }))
+        })
+
+        it('invalid argument', function () {
+          try {
+            invoke(composer.repeat('foo'))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+      })
+
+      describe('retry', function () {
+        it('success', function () {
+          return invoke(composer.let({ x: 2 }, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42)))
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 }))
+        })
+
+        it('failure', function () {
+          return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42)))
+            .then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result.error, 'foo'))
+        })
+
+        it('invalid argument', function () {
+          try {
+            invoke(composer.retry('foo'))
+            assert.fail()
+          } catch (error) {
+            assert.ok(error.message.startsWith('Invalid argument'))
+          }
+        })
+      })
+    })
+  })
+
+  describe('compositions', function () {
+    describe('collatz', function () {
+      it('composition must return { n: 1 }', function () {
+        return invoke(composer.while('isNotOne', composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement')), { n: 5 })
+          .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 }))
+      })
+    })
+  })
+})
diff --git a/test/fqn.js b/test/fqn.js
new file mode 100644
index 0000000..1c7afe3
--- /dev/null
+++ b/test/fqn.js
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* eslint-env mocha */
+
+'use strict'
+
+const assert = require('assert')
+const fqn = require('../fqn')
+
+describe('fqn', function () {
+  let combos = [
+    { n: undefined, s: false, e: 'Name must be a string' },
+    { n: null, s: false, e: 'Name must be a string' },
+    { n: 0, s: false, e: 'Name must be a string' },
+    { n: 42, s: false, e: 'Name must be a string' },
+    { n: true, s: false, e: 'Name must be a string' },
+    { n: false, s: false, e: 'Name must be a string' },
+    { n: '', s: false, e: 'Name is not valid' },
+    { n: ' ', s: false, e: 'Name is not valid' },
+    { n: '/', s: false, e: 'Name is not valid' },
+    { n: '//', s: false, e: 'Name is not valid' },
+    { n: '/a', s: false, e: 'Name is not valid' },
+    { n: '/a/b/c/d', s: false, e: 'Name is not valid' },
+    { n: '/a/b/c/d/', s: false, e: 'Name is not valid' },
+    { n: 'a/b/c/d', s: false, e: 'Name is not valid' },
+    { n: '/a/ /b', s: false, e: 'Name is not valid' },
+    { n: 'a', e: false, s: '/_/a' },
+    { n: 'a/b', e: false, s: '/_/a/b' },
+    { n: 'a/b/c', e: false, s: '/a/b/c' },
+    { n: '/a/b', e: false, s: '/a/b' },
+    { n: '/a/b/c', e: false, s: '/a/b/c' }
+  ]
+  combos.forEach(({ n, s, e }) => {
+    it(typeof n === 'string' ? `'${n}'` : `${n}`, function () {
+      if (s) {
+        // good cases
+        assert.strictEqual(fqn(n), s)
+      } else {
+        // error cases
+        try {
+          fqn(n)
+          assert.fail()
+        } catch (error) {
+          assert.ok(error.message.startsWith(e))
+        }
+      }
+    })
+  })
+})
diff --git a/travis/runtimes.json b/travis/runtimes.json
new file mode 100644
index 0000000..f565907
--- /dev/null
+++ b/travis/runtimes.json
@@ -0,0 +1,30 @@
+{
+    "runtimes": {
+        "nodejs": [
+            {
+                "kind": "nodejs",
+                "image": {
+                    "prefix": "openwhisk",
+                    "name": "nodejsaction",
+                    "tag": "latest"
+                },
+                "deprecated": true
+            },
+            {
+                "kind": "nodejs:6",
+                "default": true,
+                "image": {
+                    "prefix": "openwhisk",
+                    "name": "nodejs6action",
+                    "tag": "latest"
+                },
+                "deprecated": false,
+                "stemCells": [{
+                    "count": 2,
+                    "memory": "256 MB"
+                }]
+            }
+        ]
+    },
+    "blackboxes": []
+}
diff --git a/travis/setup.sh b/travis/setup.sh
new file mode 100755
index 0000000..2ee033d
--- /dev/null
+++ b/travis/setup.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+set -e
+
+# Build script for Travis-CI.
+
+SCRIPTDIR=$(cd $(dirname "$0") && pwd)
+ROOTDIR="$SCRIPTDIR/.."
+IMAGE_PREFIX="composer"
+WHISKDIR="$ROOTDIR/openwhisk"
+
+# OpenWhisk stuff
+cd $ROOTDIR
+git clone --depth=1 https://github.com/apache/incubator-openwhisk.git openwhisk
+cd openwhisk
+./tools/travis/setup.sh
+
+cp $SCRIPTDIR/runtimes.json $WHISKDIR/ansible/files
+
+# Pull down images
+docker pull openwhisk/controller
+docker tag openwhisk/controller ${IMAGE_PREFIX}/controller
+docker pull openwhisk/invoker
+docker tag openwhisk/invoker ${IMAGE_PREFIX}/invoker
+
+# Deploy OpenWhisk
+cd $WHISKDIR/ansible
+ANSIBLE_CMD="ansible-playbook -i ${WHISKDIR}/ansible/environments/local -e docker_image_prefix=${IMAGE_PREFIX}"
+$ANSIBLE_CMD setup.yml
+$ANSIBLE_CMD prereq.yml
+$ANSIBLE_CMD couchdb.yml
+$ANSIBLE_CMD initdb.yml
+$ANSIBLE_CMD wipe.yml
+$ANSIBLE_CMD openwhisk.yml -e cli_installation_mode=remote -e limit_invocations_per_minute=600
+
+docker images
+docker ps
+
+curl -s -k https://172.17.0.1 | jq .
+curl -s -k https://172.17.0.1/api/v1 | jq .
+
+# Setup
+WHISK_APIHOST="172.17.0.1"
+WHISK_AUTH=`cat ${WHISKDIR}/ansible/files/auth.guest`
+WHISK_CLI="${WHISKDIR}/bin/wsk -i"
+
+${WHISK_CLI} property set --apihost ${WHISK_APIHOST} --auth ${WHISK_AUTH}
+${WHISK_CLI} property get


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services