├── .formatter.exs ├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ └── release-drafter.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── workflows.ex └── workflows │ ├── activity.ex │ ├── activity │ ├── choice.ex │ ├── fail.ex │ ├── map.ex │ ├── parallel.ex │ ├── pass.ex │ ├── succeed.ex │ ├── task.ex │ └── wait.ex │ ├── activity_util.ex │ ├── catcher.ex │ ├── command.ex │ ├── command │ ├── task.ex │ └── wait.ex │ ├── error.ex │ ├── event.ex │ ├── event │ ├── choice.ex │ ├── execution.ex │ ├── fail.ex │ ├── map.ex │ ├── parallel.ex │ ├── pass.ex │ ├── succeed.ex │ ├── task.ex │ └── wait.ex │ ├── execution.ex │ ├── intrinsic.ex │ ├── path.ex │ ├── payload_template.ex │ ├── reference_path.ex │ ├── retrier.ex │ ├── rule.ex │ ├── state.ex │ ├── state │ ├── choice.ex │ ├── fail.ex │ ├── map.ex │ ├── parallel.ex │ ├── pass.ex │ ├── succeed.ex │ ├── task.ex │ └── wait.ex │ ├── state_util.ex │ └── workflow.ex ├── mix.exs ├── mix.lock └── test ├── state ├── choice_test.exs ├── fail_test.exs ├── map_test.exs ├── parallel_test.exs ├── pass_test.exs ├── succeed_test.exs ├── task_test.exs └── wait_test.exs ├── test_helper.exs └── workflows_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - development 9 | workflow_dispatch: 10 | 11 | jobs: 12 | ci: 13 | name: OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }} 14 | env: 15 | MIX_ENV: test 16 | strategy: 17 | matrix: 18 | platform: [ubuntu-18.04] 19 | otp: [21.x, 22.x, 23.x] 20 | elixir: [1.9.x, 1.10.x, 1.11.x] 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: Set up Elixir 28 | uses: erlef/setup-elixir@v1 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.otp }} 32 | 33 | - name: Cache Mix 34 | uses: actions/cache@v1 35 | with: 36 | key: ${{runner.os}}-${{matrix.otp}}-${{matrix.elixir}}-mix-${{hashFiles(format('{0}{1}', github.workspace, '/mix.lock'))}} 37 | path: _build 38 | 39 | - name: Install dependencies 40 | run: mix deps.get 41 | 42 | # - name: Compile with warnings 43 | # run: mix compile --warnings-as-errors 44 | 45 | - name: Run formatter 46 | run: mix format --check-formatted 47 | 48 | # - name: Run Credo 49 | # run: mix credo 50 | 51 | - name: Run tests 52 | run: mix test 53 | 54 | - name: Retrieve PLT Cache 55 | uses: actions/cache@v1 56 | id: plt-cache 57 | with: 58 | path: priv/plts 59 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 60 | 61 | - name: Create PLTs 62 | if: steps.plt-cache.outputs.cache-hit != 'true' 63 | run: | 64 | mkdir -p priv/plts 65 | mix dialyzer --plt 66 | 67 | - name: Run dialyzer 68 | run: mix dialyzer --no-check --halt-exit-status -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Hex 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | - published 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Elixir 17 | uses: erlef/setup-elixir@v1 18 | with: 19 | elixir-version: 1.10.4 20 | otp-version: 22.2 21 | 22 | - name: Get dependencies 23 | run: mix deps.get 24 | 25 | - name: Publish to hex 26 | run: mix hex.publish --yes 27 | env: 28 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 29 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release drafter 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Release 16 | uses: softprops/action-gh-release@v1 17 | with: 18 | draft: true 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | workflows-*.tar 24 | 25 | # Cache plts 26 | priv/plts -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.0 -- 2021-04-02 4 | 5 | ### Added 6 | 7 | - Execute `Fail`, `Task`, `Map`, `Parallel`, and `Choice` activities 8 | - New `Execute` module to run workflows until command is needed 9 | - New top-level functions in `Workflows` 10 | 11 | 12 | ## 0.1.0 -- 2021-03-29 13 | 14 | ### Added 15 | 16 | - Parse activities from json-like definition 17 | - Generate events for `Task` and `Wait` activities 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Supabase 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workflows 2 | 3 | This package implements a workflow interpreter based on the 4 | [Amazon States Language](https://states-language.net/) specification. 5 | 6 | ## Installation 7 | 8 | This package can be installed by adding `workflows` to your list of 9 | dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [ 14 | {:workflows, "~> 0.1.0"} 15 | ] 16 | end 17 | ``` 18 | 19 | ## Usage 20 | 21 | 22 | 23 | Workflows implements an Amazon States Language interpreter using event-sourcing, this has the added benefit that 24 | workflows can be suspended and later recovered. 25 | 26 | Workflows are created by parsing a map with the workflow definition that conforms to the Amazon States Language 27 | specification. 28 | 29 | ```elixir 30 | workflow_definition = %{ 31 | "StartAt" => "Start", 32 | "States" => %{ 33 | "Start" => %{ 34 | "Type" => "Wait", 35 | "Seconds" => 10, 36 | "Next" => "End" 37 | }, 38 | "End" => %{ 39 | "Type" => "Succeed" 40 | } 41 | } 42 | } 43 | {:ok, workflow} = Workflows.parse(workflow_definition) 44 | ``` 45 | 46 | You can then start the workflow by calling the `Workflows.start` function and passing a context (a map containing data 47 | that is shared between all states), and the arguments passed to the initial state. 48 | The function returns `{:continue, execution, events}` if the workflow execution has to stop to wait for an external command, 49 | or `{:succeed, result, events}` if the workflow executes to termination. 50 | 51 | ```elixir 52 | ctx = %{"environment" => "staging"} 53 | args = %{"user" => "alfred@example.org"} 54 | {:continue, execution, events} = Workflows.start(workflow, ctx, args) 55 | IO.inspect events 56 | ``` 57 | 58 | The interpreter does not execute side effects like waiting for a timer or executing a task, instead it returns an event 59 | (for example, `Event.WaitStarted` or `Event.TaskStarted`) and pauses the execution. To resume execution, you should 60 | call the `Workflows.resume` function with a `Command` containing the side effect result (for example, the result of a 61 | `Task`). 62 | 63 | ```elixir 64 | wait_event = events |> get_wait_event() 65 | finish_wait = Workflows.Command.finish_waiting(wait_event) 66 | {:succeed, result, events} = Workflows.resume(execution, finish_wait) 67 | ``` 68 | 69 | You can restore a workflow execution state from its events with the `Workflows.recover` function. 70 | 71 | ```elixir 72 | {:continue, execution, new_events} = Workflows.recover(workflow, events) 73 | ``` 74 | 75 | 76 | 77 | ## License 78 | 79 | This repo is licensed under Apache 2.0. -------------------------------------------------------------------------------- /lib/workflows.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows do 2 | @external_resource readme = Path.join([__DIR__, "../README.md"]) 3 | 4 | @moduledoc readme 5 | |> File.read!() 6 | |> String.split("") 7 | |> Enum.fetch!(1) 8 | 9 | alias Workflows.{Activity, Command, Event, Execution, Workflow} 10 | 11 | @doc """ 12 | Parses a workflow definition. 13 | 14 | A workflow is defined by a map-like structure that conforms to the 15 | [Amazon States Language](https://states-language.net/) specification. 16 | 17 | ## Examples 18 | 19 | iex> {:ok, wf} = Workflows.parse(%{ 20 | ...> "Comment" => "A simple example", 21 | ...> "StartAt" => "Hello World", 22 | ...> "States" => %{ 23 | ...> "Hello World" => %{ 24 | ...> "Type" => "Task", 25 | ...> "Resource" => "do-something", 26 | ...> "End" => true 27 | ...> } 28 | ...> } 29 | ...> }) 30 | iex> wf.start_at 31 | "Hello World" 32 | 33 | """ 34 | @spec parse(map()) :: {:ok, Workflow.t()} | {:error, term()} 35 | def parse(definition), do: Workflow.parse(definition) 36 | 37 | @doc """ 38 | Starts a `workflow` execution with the given `ctx` and `args`. 39 | """ 40 | @spec start(Workflow.t(), Activity.ctx(), Activity.args()) :: 41 | Execution.execution_result() | {:error, term()} 42 | def start(workflow, ctx, args), do: Execution.start(workflow, ctx, args) 43 | 44 | @doc """ 45 | Resumes an `execution` waiting for `cmd` to continue. 46 | """ 47 | @spec resume(Execution.t(), Command.t()) :: Execution.execution_result() | {:error, term()} 48 | def resume(execution, cmd), do: Execution.resume(execution, cmd) 49 | 50 | @doc """ 51 | Recovers a `workflow` execution from `events`. 52 | """ 53 | @spec recover(Workflow.t(), list(Event.t())) :: Execution.execution_result() | {:error, term()} 54 | def recover(workflow, events), do: Execution.recover(workflow, events) 55 | end 56 | -------------------------------------------------------------------------------- /lib/workflows/activity.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | 7 | @type ctx :: any() 8 | @type args :: any() 9 | @type name :: String.t() 10 | @type transition :: {:next, name()} | :end 11 | 12 | @type t :: 13 | Activity.Choice.t() 14 | | Activity.Fail.t() 15 | | Activity.Map.t() 16 | | Activity.Parallel.t() 17 | | Activity.Pass.t() 18 | | Activity.Succeed.t() 19 | | Activity.Task.t() 20 | | Activity.Wait.t() 21 | 22 | @callback parse(name(), map()) :: {:ok, t()} | {:error, term()} 23 | @callback enter(t(), ctx(), args()) :: {:ok, Event.t()} | {:error, term()} 24 | @callback exit(t(), ctx(), args(), args()) :: {:ok, Event.t()} | {:error, term()} 25 | 26 | @spec parse(name(), map()) :: {:ok, t()} | {:error, term()} 27 | def parse(name, state), do: do_parse(name, state) 28 | 29 | @spec enter(t(), ctx(), args()) :: {:ok, Event.t()} | {:error, term()} 30 | def enter(activity, ctx, args), do: do_enter(activity, ctx, args) 31 | 32 | @spec exit(t(), ctx(), args(), args()) :: {:ok, Event.t()} | {:error, term()} 33 | def exit(activity, ctx, args, result), do: do_exit(activity, ctx, args, result) 34 | 35 | ## Private 36 | 37 | defp do_parse(name, %{"Type" => "Choice"} = state), 38 | do: Activity.Choice.parse(name, state) 39 | 40 | defp do_parse(name, %{"Type" => "Fail"} = state), 41 | do: Activity.Fail.parse(name, state) 42 | 43 | defp do_parse(name, %{"Type" => "Map"} = state), 44 | do: Activity.Map.parse(name, state) 45 | 46 | defp do_parse(name, %{"Type" => "Parallel"} = state), 47 | do: Activity.Parallel.parse(name, state) 48 | 49 | defp do_parse(name, %{"Type" => "Pass"} = state), 50 | do: Activity.Pass.parse(name, state) 51 | 52 | defp do_parse(name, %{"Type" => "Succeed"} = state), 53 | do: Activity.Succeed.parse(name, state) 54 | 55 | defp do_parse(name, %{"Type" => "Task"} = state), 56 | do: Activity.Task.parse(name, state) 57 | 58 | defp do_parse(name, %{"Type" => "Wait"} = state), 59 | do: Activity.Wait.parse(name, state) 60 | 61 | defp do_parse(name, state) do 62 | {:error, :parse, name, state} 63 | end 64 | 65 | defp do_enter(%Activity.Choice{} = activity, ctx, args) do 66 | Activity.Choice.enter(activity, ctx, args) 67 | end 68 | 69 | defp do_enter(%Activity.Fail{} = activity, ctx, args) do 70 | Activity.Fail.enter(activity, ctx, args) 71 | end 72 | 73 | defp do_enter(%Activity.Map{} = activity, ctx, args) do 74 | Activity.Map.enter(activity, ctx, args) 75 | end 76 | 77 | defp do_enter(%Activity.Pass{} = activity, ctx, args) do 78 | Activity.Pass.enter(activity, ctx, args) 79 | end 80 | 81 | defp do_enter(%Activity.Parallel{} = activity, ctx, args) do 82 | Activity.Parallel.enter(activity, ctx, args) 83 | end 84 | 85 | defp do_enter(%Activity.Succeed{} = activity, ctx, args) do 86 | Activity.Succeed.enter(activity, ctx, args) 87 | end 88 | 89 | defp do_enter(%Activity.Task{} = activity, ctx, args) do 90 | Activity.Task.enter(activity, ctx, args) 91 | end 92 | 93 | defp do_enter(%Activity.Wait{} = activity, ctx, args) do 94 | Activity.Wait.enter(activity, ctx, args) 95 | end 96 | 97 | defp do_exit(%Activity.Choice{} = activity, ctx, args, result) do 98 | Activity.Choice.exit(activity, ctx, args, result) 99 | end 100 | 101 | defp do_exit(%Activity.Fail{} = activity, ctx, args, result) do 102 | Activity.Fail.exit(activity, ctx, args, result) 103 | end 104 | 105 | defp do_exit(%Activity.Map{} = activity, ctx, args, result) do 106 | Activity.Map.exit(activity, ctx, args, result) 107 | end 108 | 109 | defp do_exit(%Activity.Pass{} = activity, ctx, args, result) do 110 | Activity.Pass.exit(activity, ctx, args, result) 111 | end 112 | 113 | defp do_exit(%Activity.Parallel{} = activity, ctx, args, result) do 114 | Activity.Parallel.exit(activity, ctx, args, result) 115 | end 116 | 117 | defp do_exit(%Activity.Succeed{} = activity, ctx, args, result) do 118 | Activity.Succeed.exit(activity, ctx, args, result) 119 | end 120 | 121 | defp do_exit(%Activity.Task{} = activity, ctx, args, result) do 122 | Activity.Task.exit(activity, ctx, args, result) 123 | end 124 | 125 | defp do_exit(%Activity.Wait{} = activity, ctx, args, result) do 126 | Activity.Wait.exit(activity, ctx, args, result) 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/workflows/activity/choice.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Choice do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Error 7 | alias Workflows.Event 8 | alias Workflows.Path 9 | alias Workflows.Rule 10 | 11 | @behaviour Activity 12 | 13 | @type t :: %__MODULE__{ 14 | name: Activity.name(), 15 | default: Activity.name() | nil, 16 | choices: nonempty_list(Rule.t()), 17 | input_path: Path.t() | nil, 18 | output_path: Path.t() | nil 19 | } 20 | 21 | defstruct [:name, :default, :choices, :input_path, :output_path] 22 | 23 | @impl Activity 24 | def parse(state_name, definition) do 25 | with {:ok, default} <- parse_default(definition), 26 | {:ok, choices} <- parse_choices(definition), 27 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 28 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition) do 29 | state = %__MODULE__{ 30 | name: state_name, 31 | default: default, 32 | choices: choices, 33 | input_path: input_path, 34 | output_path: output_path 35 | } 36 | 37 | {:ok, state} 38 | end 39 | end 40 | 41 | @impl Activity 42 | def enter(activity, _ctx, args) do 43 | with {:ok, effective_args} <- ActivityUtil.apply_input_path(activity, args) do 44 | event = %Event.ChoiceEntered{ 45 | activity: activity.name, 46 | scope: [], 47 | args: effective_args 48 | } 49 | 50 | {:ok, event} 51 | end 52 | end 53 | 54 | @impl Activity 55 | def exit(activity, _ctx, _args, result) do 56 | with {:ok, transition} <- match_rule(activity, result), 57 | {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 58 | event = %Event.ChoiceExited{ 59 | activity: activity.name, 60 | scope: [], 61 | result: effective_result, 62 | transition: transition 63 | } 64 | 65 | {:ok, event} 66 | end 67 | end 68 | 69 | ## Private 70 | 71 | defp parse_default(definition) do 72 | default = Map.get(definition, "Default") 73 | {:ok, default} 74 | end 75 | 76 | defp parse_choices(definition) do 77 | with {:ok, choices} <- state_choices(definition) do 78 | collect_state_rules(choices, []) 79 | end 80 | end 81 | 82 | defp state_choices(%{"Choices" => []}), 83 | do: state_choices(nil) 84 | 85 | defp state_choices(%{"Choices" => choices}) when is_list(choices) do 86 | {:ok, choices} 87 | end 88 | 89 | defp state_choices(_) do 90 | {:error, :empty_choices} 91 | end 92 | 93 | defp collect_state_rules([], acc), do: {:ok, acc} 94 | 95 | defp collect_state_rules([choice | choices], acc) do 96 | case Rule.create(choice) do 97 | {:ok, rule} -> 98 | collect_state_rules(choices, [rule | acc]) 99 | 100 | {:error, _err} -> 101 | {:error, :invalid_choice_rule} 102 | end 103 | end 104 | 105 | defp match_rule(activity, args) do 106 | case Enum.find(activity.choices, fn rule -> Rule.call(rule, args) end) do 107 | nil -> 108 | default_next = activity.default 109 | 110 | if default_next == nil do 111 | error = 112 | Error.create("States.NoChoiceMatched", "No Choices matched and no Default specified") 113 | 114 | {:failure, error} 115 | else 116 | {:ok, {:next, default_next}} 117 | end 118 | 119 | rule -> 120 | {:ok, {:next, rule.next}} 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/workflows/activity/fail.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Fail do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.Error 7 | 8 | @behaviour Activity 9 | 10 | @type t :: %__MODULE__{ 11 | name: Activity.name(), 12 | error: String.t(), 13 | cause: String.t() 14 | } 15 | 16 | defstruct [:name, :error, :cause] 17 | 18 | @impl Activity 19 | def parse(state_name, definition) do 20 | with {:ok, error} <- parse_error(definition), 21 | {:ok, cause} <- parse_cause(definition) do 22 | state = %__MODULE__{ 23 | name: state_name, 24 | error: error, 25 | cause: cause 26 | } 27 | 28 | {:ok, state} 29 | end 30 | end 31 | 32 | @impl Activity 33 | def enter(activity, _ctx, args) do 34 | event = %Event.FailEntered{ 35 | activity: activity.name, 36 | scope: [], 37 | args: args 38 | } 39 | 40 | {:ok, event} 41 | end 42 | 43 | @impl Activity 44 | def exit(activity, _ctx, _args, _result) do 45 | error = Error.create(activity.error, activity.cause) 46 | 47 | event = %Event.FailExited{ 48 | activity: activity.name, 49 | scope: [], 50 | error: error 51 | } 52 | 53 | {:ok, event} 54 | end 55 | 56 | ## Private 57 | 58 | defp parse_error(%{"Error" => error}), do: {:ok, error} 59 | defp parse_error(_definition), do: {:error, :missing_error} 60 | 61 | defp parse_cause(%{"Cause" => cause}), do: {:ok, cause} 62 | defp parse_cause(_definition), do: {:error, :missing_cause} 63 | end 64 | -------------------------------------------------------------------------------- /lib/workflows/activity/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Map do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Catcher 7 | alias Workflows.Event 8 | alias Workflows.Path 9 | alias Workflows.ReferencePath 10 | alias Workflows.PayloadTemplate 11 | alias Workflows.Retrier 12 | alias Workflows.Workflow 13 | 14 | @behaviour Activity 15 | 16 | @type t :: %__MODULE__{ 17 | name: Activity.name(), 18 | iterator: Workflow.t(), 19 | items_path: ReferencePath.t() | nil, 20 | max_concurrency: non_neg_integer(), 21 | transition: Activity.transition(), 22 | input_path: Path.t() | nil, 23 | output_path: Path.t() | nil, 24 | result_path: ReferencePath.t() | nil, 25 | parameters: PayloadTemplate.t() | nil, 26 | result_selector: PayloadTemplate.t() | nil, 27 | retry: list(Retrier.t()), 28 | catch: list(Catcher.t()) 29 | } 30 | 31 | defstruct [ 32 | :name, 33 | :iterator, 34 | :items_path, 35 | :max_concurrency, 36 | :transition, 37 | :input_path, 38 | :output_path, 39 | :result_path, 40 | :parameters, 41 | :result_selector, 42 | :retry, 43 | :catch 44 | ] 45 | 46 | @impl Activity 47 | def parse(state_name, definition) do 48 | with {:ok, iterator} <- parse_iterator(definition), 49 | {:ok, items_path} <- parse_items_path(definition), 50 | {:ok, max_concurrency} <- parse_max_concurrency(definition), 51 | {:ok, transition} <- ActivityUtil.parse_transition(definition), 52 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 53 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition), 54 | {:ok, result_path} <- ActivityUtil.parse_result_path(definition), 55 | {:ok, parameters} <- ActivityUtil.parse_parameters(definition), 56 | {:ok, result_selector} <- ActivityUtil.parse_result_selector(definition), 57 | {:ok, retry} <- ActivityUtil.parse_retry(definition), 58 | {:ok, catch_} <- ActivityUtil.parse_catch(definition) do 59 | state = %__MODULE__{ 60 | name: state_name, 61 | iterator: iterator, 62 | items_path: items_path, 63 | max_concurrency: max_concurrency, 64 | transition: transition, 65 | input_path: input_path, 66 | output_path: output_path, 67 | result_path: result_path, 68 | parameters: parameters, 69 | result_selector: result_selector, 70 | retry: retry, 71 | catch: catch_ 72 | } 73 | 74 | {:ok, state} 75 | end 76 | end 77 | 78 | @impl Activity 79 | def enter(activity, ctx, args) do 80 | with {:ok, args} <- ActivityUtil.apply_input_path(activity, args), 81 | {:ok, effective_args} <- ActivityUtil.apply_parameters(activity, ctx, args) do 82 | event = %Event.MapEntered{ 83 | activity: activity.name, 84 | scope: [], 85 | args: effective_args 86 | } 87 | 88 | {:ok, event} 89 | end 90 | end 91 | 92 | @impl Activity 93 | def exit(activity, ctx, args, result) do 94 | with {:ok, result} <- ActivityUtil.apply_result_selector(activity, ctx, result), 95 | {:ok, result} <- ActivityUtil.apply_result_path(activity, ctx, result, args), 96 | {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 97 | event = %Event.MapExited{ 98 | activity: activity.name, 99 | scope: [], 100 | result: effective_result, 101 | transition: activity.transition 102 | } 103 | 104 | {:ok, event} 105 | end 106 | end 107 | 108 | def start_map(activity, _ctx, args) do 109 | event = %Event.MapStarted{ 110 | activity: activity.name, 111 | scope: [], 112 | args: args 113 | } 114 | 115 | {:ok, event} 116 | end 117 | 118 | def complete_map(activity, _ctx, result) do 119 | event = %Event.MapSucceeded{ 120 | activity: activity.name, 121 | scope: [], 122 | result: result 123 | } 124 | 125 | {:ok, event} 126 | end 127 | 128 | ## Private 129 | 130 | defp parse_iterator(%{"Iterator" => iterator}) do 131 | Workflow.parse(iterator) 132 | end 133 | 134 | defp parse_iterator(_definition), do: {:error, :missing_iterator} 135 | 136 | defp parse_items_path(%{"ItemsPath" => path}) do 137 | ReferencePath.create(path) 138 | end 139 | 140 | defp parse_items_path(_definition) do 141 | # The default value of "ItemsPath" is "$", which is to say the whole effective input. 142 | ReferencePath.create("$") 143 | end 144 | 145 | defp parse_max_concurrency(%{"MaxConcurrency" => concurrency}) do 146 | if is_integer(concurrency) and concurrency >= 0 do 147 | {:ok, concurrency} 148 | else 149 | {:error, :invalid_max_concurrency} 150 | end 151 | end 152 | 153 | defp parse_max_concurrency(_definition), do: {:ok, 0} 154 | end 155 | -------------------------------------------------------------------------------- /lib/workflows/activity/parallel.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Parallel do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Catcher 7 | alias Workflows.Event 8 | alias Workflows.Path 9 | alias Workflows.ReferencePath 10 | alias Workflows.PayloadTemplate 11 | alias Workflows.Retrier 12 | alias Workflows.Workflow 13 | 14 | @behaviour Activity 15 | 16 | @type t :: %__MODULE__{ 17 | name: Activity.name(), 18 | branches: nonempty_list(Workflow.t()), 19 | transition: Activity.transition(), 20 | input_path: Path.t() | nil, 21 | output_path: Path.t() | nil, 22 | result_path: ReferencePath.t() | nil, 23 | parameters: PayloadTemplate.t() | nil, 24 | result_selector: PayloadTemplate.t() | nil, 25 | retry: list(Retrier.t()), 26 | catch: list(Catcher.t()) 27 | } 28 | 29 | defstruct [ 30 | :name, 31 | :branches, 32 | :transition, 33 | :input_path, 34 | :output_path, 35 | :result_path, 36 | :parameters, 37 | :result_selector, 38 | :retry, 39 | :catch 40 | ] 41 | 42 | @impl Activity 43 | def parse(state_name, definition) do 44 | with {:ok, branches} <- parse_branches(definition), 45 | {:ok, transition} <- ActivityUtil.parse_transition(definition), 46 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 47 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition), 48 | {:ok, result_path} <- ActivityUtil.parse_result_path(definition), 49 | {:ok, parameters} <- ActivityUtil.parse_parameters(definition), 50 | {:ok, result_selector} <- ActivityUtil.parse_result_selector(definition), 51 | {:ok, retry} <- ActivityUtil.parse_retry(definition), 52 | {:ok, catch_} <- ActivityUtil.parse_catch(definition) do 53 | state = %__MODULE__{ 54 | name: state_name, 55 | branches: branches, 56 | transition: transition, 57 | input_path: input_path, 58 | output_path: output_path, 59 | result_path: result_path, 60 | parameters: parameters, 61 | result_selector: result_selector, 62 | retry: retry, 63 | catch: catch_ 64 | } 65 | 66 | {:ok, state} 67 | end 68 | end 69 | 70 | @impl Activity 71 | def enter(activity, ctx, args) do 72 | with {:ok, args} <- ActivityUtil.apply_input_path(activity, args), 73 | {:ok, effective_args} <- ActivityUtil.apply_parameters(activity, ctx, args) do 74 | event = %Event.ParallelEntered{ 75 | activity: activity.name, 76 | scope: [], 77 | args: effective_args 78 | } 79 | 80 | {:ok, event} 81 | end 82 | end 83 | 84 | @impl Activity 85 | def exit(activity, ctx, args, result) do 86 | with {:ok, result} <- ActivityUtil.apply_result_selector(activity, ctx, result), 87 | {:ok, result} <- ActivityUtil.apply_result_path(activity, ctx, result, args), 88 | {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 89 | event = %Event.ParallelExited{ 90 | activity: activity.name, 91 | scope: [], 92 | result: effective_result, 93 | transition: activity.transition 94 | } 95 | 96 | {:ok, event} 97 | end 98 | end 99 | 100 | def start_parallel(activity, _ctx, args) do 101 | event = %Event.ParallelStarted{ 102 | activity: activity.name, 103 | scope: [], 104 | args: args 105 | } 106 | 107 | {:ok, event} 108 | end 109 | 110 | def complete_parallel(activity, _ctx, result) do 111 | event = %Event.ParallelSucceeded{ 112 | activity: activity.name, 113 | scope: [], 114 | result: result 115 | } 116 | 117 | {:ok, event} 118 | end 119 | 120 | ## Private 121 | 122 | defp parse_branches(%{"Branches" => branches}) when is_list(branches) do 123 | collect_branches(branches, []) 124 | end 125 | 126 | defp parse_branches(_definition), do: {:error, :empty_branches} 127 | 128 | defp collect_branches([], acc), do: {:ok, Enum.reverse(acc)} 129 | 130 | defp collect_branches([branch | branches], acc) do 131 | with {:ok, branch} <- Workflow.parse(branch) do 132 | collect_branches(branches, [branch | acc]) 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/workflows/activity/pass.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Pass do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Event 7 | alias Workflows.Path 8 | alias Workflows.ReferencePath 9 | alias Workflows.PayloadTemplate 10 | 11 | @behaviour Activity 12 | 13 | @type t :: %__MODULE__{ 14 | name: Activity.name(), 15 | result: Activity.args(), 16 | transition: Activity.transition(), 17 | input_path: Path.t() | nil, 18 | output_path: Path.t() | nil, 19 | result_path: ReferencePath.t() | nil, 20 | parameters: PayloadTemplate.t() | nil 21 | } 22 | 23 | defstruct [:name, :result, :transition, :input_path, :output_path, :result_path, :parameters] 24 | 25 | @impl Activity 26 | def parse(state_name, definition) do 27 | result = parse_result(definition) 28 | 29 | with {:ok, transition} <- ActivityUtil.parse_transition(definition), 30 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 31 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition), 32 | {:ok, result_path} <- ActivityUtil.parse_result_path(definition), 33 | {:ok, parameters} <- ActivityUtil.parse_parameters(definition) do 34 | state = %__MODULE__{ 35 | name: state_name, 36 | result: result, 37 | transition: transition, 38 | input_path: input_path, 39 | output_path: output_path, 40 | result_path: result_path, 41 | parameters: parameters 42 | } 43 | 44 | {:ok, state} 45 | end 46 | end 47 | 48 | @impl Activity 49 | def enter(activity, ctx, args) do 50 | with {:ok, args} <- ActivityUtil.apply_input_path(activity, args), 51 | {:ok, effective_args} <- ActivityUtil.apply_parameters(activity, ctx, args) do 52 | event = %Event.PassEntered{ 53 | activity: activity.name, 54 | scope: [], 55 | args: effective_args 56 | } 57 | 58 | {:ok, event} 59 | end 60 | end 61 | 62 | @impl Activity 63 | def exit(activity, ctx, args, result) do 64 | with {:ok, result} <- ActivityUtil.apply_result_path(activity, ctx, result, args), 65 | {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 66 | event = %Event.PassExited{ 67 | activity: activity.name, 68 | scope: [], 69 | result: effective_result, 70 | transition: activity.transition 71 | } 72 | 73 | {:ok, event} 74 | end 75 | end 76 | 77 | ## Private 78 | 79 | defp parse_result(definition) do 80 | Map.get(definition, "Result", nil) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/workflows/activity/succeed.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Succeed do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.Path 7 | alias Workflows.ActivityUtil 8 | 9 | @behaviour Activity 10 | 11 | @type t :: %__MODULE__{ 12 | name: Activity.name(), 13 | input_path: Path.t() | nil, 14 | output_path: Path.t() | nil 15 | } 16 | 17 | defstruct [:name, :input_path, :output_path] 18 | 19 | @impl Activity 20 | def parse(state_name, definition) do 21 | with {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 22 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition) do 23 | state = %__MODULE__{ 24 | name: state_name, 25 | input_path: input_path, 26 | output_path: output_path 27 | } 28 | 29 | {:ok, state} 30 | end 31 | end 32 | 33 | @impl Activity 34 | def enter(activity, _ctx, args) do 35 | with {:ok, effective_args} <- ActivityUtil.apply_input_path(activity, args) do 36 | event = %Event.SucceedEntered{ 37 | activity: activity.name, 38 | scope: [], 39 | args: effective_args 40 | } 41 | 42 | {:ok, event} 43 | end 44 | end 45 | 46 | @impl Activity 47 | def exit(activity, _ctx, _args, result) do 48 | with {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 49 | event = %Event.SucceedExited{ 50 | activity: activity.name, 51 | scope: [], 52 | result: effective_result 53 | } 54 | 55 | {:ok, event} 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/workflows/activity/task.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Task do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Catcher 7 | alias Workflows.Event 8 | alias Workflows.Path 9 | alias Workflows.PayloadTemplate 10 | alias Workflows.ReferencePath 11 | alias Workflows.Retrier 12 | 13 | @behaviour Activity 14 | 15 | @type t :: %__MODULE__{ 16 | name: Activity.name(), 17 | resource: String.t(), 18 | timeout: {:value, pos_integer()} | {:reference, ReferencePath.t()} | nil, 19 | heartbeat: {:value, pos_integer()} | {:reference, ReferencePath.t()} | nil, 20 | transition: Activity.transition(), 21 | input_path: Path.t() | nil, 22 | output_path: Path.t() | nil, 23 | result_path: ReferencePath.t() | nil, 24 | parameters: PayloadTemplate.t() | nil, 25 | result_selector: PayloadTemplate.t() | nil, 26 | retry: list(Retrier.t()), 27 | catch: list(Catcher.t()) 28 | } 29 | 30 | defstruct [ 31 | :name, 32 | :resource, 33 | :timeout, 34 | :heartbeat, 35 | :transition, 36 | :input_path, 37 | :output_path, 38 | :result_path, 39 | :parameters, 40 | :result_selector, 41 | :retry, 42 | :catch 43 | ] 44 | 45 | @impl Activity 46 | def parse(state_name, definition) do 47 | with {:ok, resource} <- parse_resource(definition), 48 | {:ok, timeout} <- parse_timeout(definition), 49 | {:ok, heartbeat} <- parse_heartbeat(definition), 50 | {:ok, transition} <- ActivityUtil.parse_transition(definition), 51 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 52 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition), 53 | {:ok, result_path} <- ActivityUtil.parse_result_path(definition), 54 | {:ok, parameters} <- ActivityUtil.parse_parameters(definition), 55 | {:ok, result_selector} <- ActivityUtil.parse_result_selector(definition), 56 | {:ok, retry} <- ActivityUtil.parse_retry(definition), 57 | {:ok, catch_} <- ActivityUtil.parse_catch(definition) do 58 | state = %__MODULE__{ 59 | name: state_name, 60 | resource: resource, 61 | timeout: timeout, 62 | heartbeat: heartbeat, 63 | transition: transition, 64 | input_path: input_path, 65 | output_path: output_path, 66 | result_path: result_path, 67 | parameters: parameters, 68 | result_selector: result_selector, 69 | retry: retry, 70 | catch: catch_ 71 | } 72 | 73 | {:ok, state} 74 | end 75 | end 76 | 77 | @impl Activity 78 | def enter(activity, ctx, args) do 79 | with {:ok, args} <- ActivityUtil.apply_input_path(activity, args), 80 | {:ok, effective_args} <- ActivityUtil.apply_parameters(activity, ctx, args) do 81 | event = %Event.TaskEntered{ 82 | activity: activity.name, 83 | scope: [], 84 | args: effective_args 85 | } 86 | 87 | {:ok, event} 88 | end 89 | end 90 | 91 | @impl Activity 92 | def exit(activity, ctx, args, result) do 93 | with {:ok, result} <- ActivityUtil.apply_result_selector(activity, ctx, result), 94 | {:ok, result} <- ActivityUtil.apply_result_path(activity, ctx, result, args), 95 | {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 96 | event = %Event.TaskExited{ 97 | activity: activity.name, 98 | scope: [], 99 | result: effective_result, 100 | transition: activity.transition 101 | } 102 | 103 | {:ok, event} 104 | end 105 | end 106 | 107 | def start_task(activity, _ctx, args) do 108 | event = %Event.TaskStarted{ 109 | activity: activity.name, 110 | scope: [], 111 | resource: activity.resource, 112 | args: args 113 | } 114 | 115 | {:ok, event} 116 | end 117 | 118 | def complete_task(activity, _ctx, result) do 119 | event = %Event.TaskSucceeded{ 120 | activity: activity.name, 121 | scope: [], 122 | result: result 123 | } 124 | 125 | {:ok, event} 126 | end 127 | 128 | def retry_task(activity, _ctx, args, error, wait) do 129 | event = %Event.TaskRetried{ 130 | activity: activity.name, 131 | scope: [], 132 | resource: activity.resource, 133 | args: args, 134 | error: error, 135 | wait: wait 136 | } 137 | 138 | {:ok, event} 139 | end 140 | 141 | def fail_task(activity, _ctx, error) do 142 | event = %Event.TaskFailed{ 143 | activity: activity.name, 144 | scope: [], 145 | error: error 146 | } 147 | 148 | {:ok, event} 149 | end 150 | 151 | ## Private 152 | 153 | defp parse_resource(%{"Resource" => resource}) do 154 | if resource == nil do 155 | {:error, :missing_resource} 156 | else 157 | {:ok, resource} 158 | end 159 | end 160 | 161 | defp parse_resource(_definition), do: {:error, "Must have Resource"} 162 | 163 | defp parse_timeout(%{"TimeoutSeconds" => _, "TimeoutSecondsPath" => _}) do 164 | {:error, :multiple_timeout} 165 | end 166 | 167 | defp parse_timeout(%{"TimeoutSeconds" => seconds}) do 168 | if is_integer(seconds) and seconds > 0 do 169 | {:ok, {:value, seconds}} 170 | else 171 | {:error, :invalid_timeout} 172 | end 173 | end 174 | 175 | defp parse_timeout(%{"TimeoutSecondsPath" => path}) do 176 | with {:ok, path} <- ReferencePath.create(path) do 177 | {:ok, {:reference, path}} 178 | end 179 | end 180 | 181 | defp parse_timeout(_definition), do: {:ok, nil} 182 | 183 | defp parse_heartbeat(%{"HeartbeatSeconds" => _, "HeartbeatSecondsPath" => _}) do 184 | {:error, :multiple_heartbeat} 185 | end 186 | 187 | defp parse_heartbeat(%{"HeartbeatSeconds" => seconds}) do 188 | if is_integer(seconds) and seconds > 0 do 189 | {:ok, {:value, seconds}} 190 | else 191 | {:error, :invalid_heartbeat} 192 | end 193 | end 194 | 195 | defp parse_heartbeat(%{"HeartbeatSecondsPath" => path}) do 196 | with {:ok, path} <- ReferencePath.create(path) do 197 | {:ok, {:reference, path}} 198 | end 199 | end 200 | 201 | defp parse_heartbeat(_definition), do: {:ok, nil} 202 | end 203 | -------------------------------------------------------------------------------- /lib/workflows/activity/wait.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Activity.Wait do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.ActivityUtil 6 | alias Workflows.Event 7 | alias Workflows.Path 8 | alias Workflows.ReferencePath 9 | 10 | @behaviour Activity 11 | 12 | @type wait :: 13 | {:seconds, pos_integer()} 14 | | {:seconds_path, Path.t()} 15 | | {:timestamp, DateTime.t()} 16 | | {:timestamp_path, Path.t()} 17 | 18 | @type t :: %__MODULE__{ 19 | name: Activity.name(), 20 | wait: wait(), 21 | transition: Activity.transition(), 22 | input_path: Path.t() | nil, 23 | output_path: Path.t() | nil 24 | } 25 | 26 | @seconds "Seconds" 27 | @seconds_path "SecondsPath" 28 | @timestamp "Timestamp" 29 | @timestamp_path "TimestampPath" 30 | 31 | defstruct [:name, :wait, :transition, :input_path, :output_path] 32 | 33 | @impl Activity 34 | def parse(activity_name, definition) do 35 | with {:ok, wait} <- parse_wait(definition), 36 | {:ok, transition} <- ActivityUtil.parse_transition(definition), 37 | {:ok, input_path} <- ActivityUtil.parse_input_path(definition), 38 | {:ok, output_path} <- ActivityUtil.parse_output_path(definition) do 39 | state = %__MODULE__{ 40 | name: activity_name, 41 | wait: wait, 42 | transition: transition, 43 | input_path: input_path, 44 | output_path: output_path 45 | } 46 | 47 | {:ok, state} 48 | end 49 | end 50 | 51 | @impl Activity 52 | def enter(activity, _ctx, args) do 53 | with {:ok, effective_args} <- ActivityUtil.apply_input_path(activity, args) do 54 | event = %Event.WaitEntered{ 55 | activity: activity.name, 56 | scope: [], 57 | args: effective_args 58 | } 59 | 60 | {:ok, event} 61 | end 62 | end 63 | 64 | @impl Activity 65 | def exit(activity, _ctx, _args, result) do 66 | with {:ok, effective_result} <- ActivityUtil.apply_output_path(activity, result) do 67 | event = %Event.WaitExited{ 68 | activity: activity.name, 69 | scope: [], 70 | result: effective_result, 71 | transition: activity.transition 72 | } 73 | 74 | {:ok, event} 75 | end 76 | end 77 | 78 | def start_wait(activity, _ctx, args) do 79 | with {:ok, wait} <- resolve_wait(activity.wait, args) do 80 | event = %Event.WaitStarted{ 81 | activity: activity.name, 82 | scope: [], 83 | wait: wait 84 | } 85 | 86 | {:ok, event} 87 | end 88 | end 89 | 90 | def finish_waiting(activity, _ctx) do 91 | event = %Event.WaitSucceeded{ 92 | activity: activity.name, 93 | scope: [] 94 | } 95 | 96 | {:ok, event} 97 | end 98 | 99 | ## Private 100 | 101 | defp parse_wait(%{"Seconds" => seconds} = state) do 102 | with :ok <- validate_no_extra_keys(state, [@seconds_path, @timestamp, @timestamp_path]) do 103 | if is_integer(seconds) and seconds > 0 do 104 | {:ok, {:seconds, seconds}} 105 | else 106 | {:error, :invalid_seconds} 107 | end 108 | end 109 | end 110 | 111 | defp parse_wait(%{"SecondsPath" => seconds_path} = state) do 112 | with :ok <- validate_no_extra_keys(state, [@seconds, @timestamp, @timestamp_path]), 113 | {:ok, seconds} <- ReferencePath.create(seconds_path) do 114 | {:ok, {:seconds_path, seconds}} 115 | end 116 | end 117 | 118 | defp parse_wait(%{"Timestamp" => timestamp} = state) do 119 | with :ok <- validate_no_extra_keys(state, [@seconds, @seconds_path, @timestamp_path]), 120 | {:ok, timestamp, _} <- DateTime.from_iso8601(timestamp) do 121 | {:ok, {:timestamp, timestamp}} 122 | end 123 | end 124 | 125 | defp parse_wait(%{"TimestampPath" => timestamp_path} = state) do 126 | with :ok <- validate_no_extra_keys(state, [@seconds, @seconds_path, @timestamp]), 127 | {:ok, timestamp} <- ReferencePath.create(timestamp_path) do 128 | {:ok, {:timestamp_path, timestamp}} 129 | end 130 | end 131 | 132 | defp parse_wait(_state) do 133 | {:error, :missing_fields} 134 | end 135 | 136 | defp validate_no_extra_keys(state, other_keys) do 137 | if state_has_keys(state, other_keys) do 138 | {:error, :invalid_fields} 139 | end 140 | 141 | :ok 142 | end 143 | 144 | defp state_has_keys(state, keys) do 145 | Enum.any?(keys, fn key -> Map.has_key?(state, key) end) 146 | end 147 | 148 | defp resolve_wait({:seconds_path, path}, args) do 149 | with {:ok, seconds} <- ReferencePath.query(path, args) do 150 | {:ok, {:seconds, seconds}} 151 | end 152 | end 153 | 154 | defp resolve_wait({:timestamp_path, path}, args) do 155 | with {:ok, timestamp} <- ReferencePath.query(path, args) do 156 | {:ok, {:timestamp, timestamp}} 157 | end 158 | end 159 | 160 | defp resolve_wait(wait, _args), do: {:ok, wait} 161 | end 162 | -------------------------------------------------------------------------------- /lib/workflows/activity_util.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.ActivityUtil do 2 | @moduledoc false 3 | 4 | alias Workflows.Catcher 5 | alias Workflows.Path 6 | alias Workflows.PayloadTemplate 7 | alias Workflows.ReferencePath 8 | alias Workflows.Retrier 9 | 10 | ## Parse helpers 11 | 12 | def parse_transition(%{"Next" => _, "End" => _}) do 13 | {:error, :multiple_transition} 14 | end 15 | 16 | def parse_transition(%{"Next" => next}) do 17 | {:ok, {:next, next}} 18 | end 19 | 20 | def parse_transition(%{"End" => true}) do 21 | {:ok, :end} 22 | end 23 | 24 | def parse_transition(_state) do 25 | {:error, :missing_transition} 26 | end 27 | 28 | def parse_input_path(%{"InputPath" => path}) do 29 | if path == nil do 30 | {:ok, nil} 31 | else 32 | Path.create(path) 33 | end 34 | end 35 | 36 | def parse_input_path(_state) do 37 | Path.create("$") 38 | end 39 | 40 | def parse_parameters(%{"Parameters" => params}) do 41 | PayloadTemplate.create(params) 42 | end 43 | 44 | def parse_parameters(_state), do: {:ok, nil} 45 | 46 | def parse_result_selector(%{"ResultSelector" => params}) do 47 | PayloadTemplate.create(params) 48 | end 49 | 50 | def parse_result_selector(_state), do: {:ok, nil} 51 | 52 | def parse_result_path(%{"ResultPath" => path}) do 53 | if path == nil do 54 | {:ok, nil} 55 | else 56 | ReferencePath.create(path) 57 | end 58 | end 59 | 60 | def parse_result_path(_state) do 61 | ReferencePath.create("$") 62 | end 63 | 64 | def parse_output_path(%{"OutputPath" => path}) do 65 | if path == nil do 66 | {:ok, nil} 67 | else 68 | Path.create(path) 69 | end 70 | end 71 | 72 | def parse_output_path(_state) do 73 | Path.create("$") 74 | end 75 | 76 | def parse_retry(%{"Retry" => retries}), do: do_parse_retry(retries, []) 77 | 78 | def parse_retry(_retry), do: {:ok, []} 79 | 80 | def parse_catch(%{"Catch" => catchers}), do: do_parse_catch(catchers, []) 81 | 82 | def parse_catch(_retry), do: {:ok, []} 83 | 84 | ## Apply input/output transforms 85 | 86 | def apply_input_path(activity, args) do 87 | apply_path(activity.input_path, args) 88 | end 89 | 90 | def apply_output_path(activity, args) do 91 | apply_path(activity.output_path, args) 92 | end 93 | 94 | def apply_parameters(activity, ctx, args) do 95 | PayloadTemplate.apply(activity.parameters, ctx, args) 96 | end 97 | 98 | def apply_result_selector(activity, ctx, args) do 99 | PayloadTemplate.apply(activity.result_selector, ctx, args) 100 | end 101 | 102 | def apply_result_path(activity, _ctx, args, state_args) do 103 | if activity.result_path == nil do 104 | {:ok, state_args} 105 | else 106 | ReferencePath.apply(activity.result_path, args, state_args) 107 | end 108 | end 109 | 110 | ## Private 111 | 112 | defp do_parse_retry([retrier | retriers], acc) do 113 | with {:ok, retrier} <- Retrier.create(retrier) do 114 | do_parse_retry(retriers, [retrier | acc]) 115 | end 116 | end 117 | 118 | defp do_parse_retry([], acc) do 119 | {:ok, Enum.reverse(acc)} 120 | end 121 | 122 | defp do_parse_catch([catcher | catchers], acc) do 123 | with {:ok, catcher} <- Catcher.create(catcher) do 124 | do_parse_catch(catchers, [catcher | acc]) 125 | end 126 | end 127 | 128 | defp do_parse_catch([], acc) do 129 | {:ok, Enum.reverse(acc)} 130 | end 131 | 132 | defp apply_path(nil, _args) do 133 | # If the value of InputPath is null, that means that the raw input is discarded, and the effective input for 134 | # the state is an empty JSON object, {}. Note that having a value of null is different from the 135 | # "InputPath" field being absent. 136 | 137 | # If the value of OutputPath is null, that means the input and result are discarded, and the effective output 138 | # from the state is an empty JSON object, {}. 139 | {:ok, %{}} 140 | end 141 | 142 | defp apply_path(path, args) do 143 | Path.query(path, args) 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/workflows/catcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Catcher do 2 | @moduledoc false 3 | 4 | alias Workflows.{Activity, Error} 5 | 6 | @opaque t :: %__MODULE__{ 7 | errors: list(String.t()), 8 | next: Activity.name() 9 | } 10 | 11 | defstruct [:errors, :next] 12 | 13 | @spec create(map()) :: {:ok, t()} | {:error, term()} 14 | def create(%{"ErrorEquals" => errors, "Next" => next}) when is_list(errors) do 15 | catcher = %__MODULE__{ 16 | errors: errors, 17 | next: next 18 | } 19 | 20 | {:ok, catcher} 21 | end 22 | 23 | def create(_definition) do 24 | {:error, :invalid_catcher} 25 | end 26 | 27 | @doc """ 28 | Returns true if any of the catchers match the error. 29 | """ 30 | @spec matches?(t(), Error.t()) :: boolean() 31 | def matches?(catcher, error) do 32 | Enum.any?(catcher.errors, fn e -> e == "States.ALL" || e == error.name end) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/workflows/command.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Command do 2 | @moduledoc false 3 | 4 | alias Workflows.{Activity, Command, Event, Error, Execution} 5 | 6 | @type t :: struct() 7 | 8 | @spec pop_scope(t()) :: {Execution.scope() | nil, t()} 9 | def pop_scope(command) do 10 | Map.get_and_update(command, :scope, fn 11 | [] -> {nil, []} 12 | [current | scope] -> {current, scope} 13 | end) 14 | end 15 | 16 | @spec finish_waiting(Event.WaitStarted.t()) :: Command.FinishWaiting.t() 17 | def finish_waiting(%Event.WaitStarted{} = event) do 18 | %Command.FinishWaiting{ 19 | activity: event.activity, 20 | scope: event.scope 21 | } 22 | end 23 | 24 | @spec complete_task(Event.TaskStarted.t(), Activity.args()) :: Command.CompleteTask.t() 25 | def complete_task(%Event.TaskStarted{} = event, result) do 26 | %Command.CompleteTask{ 27 | activity: event.activity, 28 | scope: event.scope, 29 | result: result 30 | } 31 | end 32 | 33 | @spec fail_task(Event.TaskStarted.t(), Error.t()) :: Command.FailTask.t() 34 | def fail_task(%Event.TaskStarted{} = event, error) do 35 | %Command.FailTask{ 36 | activity: event.activity, 37 | scope: event.scope, 38 | error: error 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/workflows/command/task.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Command.CompleteTask do 2 | @moduledoc false 3 | @type t :: struct() 4 | defstruct [:activity, :scope, :result] 5 | end 6 | 7 | defmodule Workflows.Command.FailTask do 8 | @moduledoc false 9 | @type t :: struct() 10 | defstruct [:activity, :scope, :error] 11 | end 12 | -------------------------------------------------------------------------------- /lib/workflows/command/wait.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Command.FinishWaiting do 2 | @moduledoc false 3 | @type t :: struct() 4 | defstruct [:activity, :scope] 5 | end 6 | -------------------------------------------------------------------------------- /lib/workflows/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Error do 2 | @moduledoc """ 3 | Represent an execution error. Errors have a name and a human-readable cause. 4 | """ 5 | 6 | @type t :: %__MODULE__{ 7 | name: String.t(), 8 | cause: String.t() 9 | } 10 | 11 | defstruct [:name, :cause] 12 | 13 | @doc """ 14 | Create a new error. 15 | """ 16 | @spec create(String.t(), String.t()) :: t() 17 | def create(name, cause) do 18 | %__MODULE__{ 19 | name: name, 20 | cause: cause 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/workflows/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event do 2 | @moduledoc """ 3 | Events generated by a workflow execution. 4 | """ 5 | 6 | alias Workflows.{Execution, Event} 7 | 8 | @type event :: any() 9 | 10 | @type t :: 11 | Event.ExecutionStarted.t() 12 | | Event.ChoiceEntered.t() 13 | | Event.ChoiceExited.t() 14 | | Event.FailEntered.t() 15 | | Event.FailExited.t() 16 | | Event.MapEntered.t() 17 | | Event.MapExited.t() 18 | | Event.MapStarted.t() 19 | | Event.MapSucceeded.t() 20 | | Event.MapFailed.t() 21 | | Event.ParallelEntered.t() 22 | | Event.ParallelExited.t() 23 | | Event.ParallelStarted.t() 24 | | Event.ParallelSucceeded.t() 25 | | Event.ParallelFailed.t() 26 | | Event.PassEntered.t() 27 | | Event.PassExited.t() 28 | | Event.SucceedEntered.t() 29 | | Event.SucceedExited.t() 30 | | Event.TaskEntered.t() 31 | | Event.TaskExited.t() 32 | | Event.TaskStarted.t() 33 | | Event.TaskSucceeded.t() 34 | | Event.TaskFailed.t() 35 | | Event.WaitEntered.t() 36 | | Event.WaitExited.t() 37 | | Event.WaitStarted.t() 38 | | Event.WaitSucceeded.t() 39 | 40 | @type maybe :: :no_event | t() 41 | 42 | defstruct [:event, :scope] 43 | 44 | @spec create(event(), Execution.scope()) :: t() 45 | def create(event, scope) do 46 | %__MODULE__{ 47 | event: event, 48 | scope: scope 49 | } 50 | end 51 | 52 | def push_scope(event, scope) do 53 | Map.update(event, :scope, [], fn existing_scope -> [scope | existing_scope] end) 54 | end 55 | 56 | def pop_scope(event) do 57 | Map.get_and_update(event, :scope, fn 58 | [] -> {nil, []} 59 | [current | scope] -> {current, scope} 60 | end) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/workflows/event/choice.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.ChoiceEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.ChoiceExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | -------------------------------------------------------------------------------- /lib/workflows/event/execution.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.ExecutionStarted do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:args, :ctx] 7 | end 8 | -------------------------------------------------------------------------------- /lib/workflows/event/fail.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.FailEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.FailExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :error] 15 | end 16 | -------------------------------------------------------------------------------- /lib/workflows/event/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.MapEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.MapExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | 17 | defmodule Workflows.Event.MapStarted do 18 | @moduledoc false 19 | 20 | @type t :: struct() 21 | 22 | defstruct [:activity, :scope, :args] 23 | end 24 | 25 | defmodule Workflows.Event.MapSucceeded do 26 | @moduledoc false 27 | 28 | @type t :: struct() 29 | 30 | defstruct [:activity, :scope, :result] 31 | end 32 | 33 | defmodule Workflows.Event.MapFailed do 34 | @moduledoc false 35 | 36 | @type t :: struct() 37 | 38 | defstruct [:activity, :scope, :error] 39 | end 40 | -------------------------------------------------------------------------------- /lib/workflows/event/parallel.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.ParallelEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.ParallelExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | 17 | defmodule Workflows.Event.ParallelStarted do 18 | @moduledoc false 19 | 20 | @type t :: struct() 21 | 22 | defstruct [:activity, :scope, :args] 23 | end 24 | 25 | defmodule Workflows.Event.ParallelSucceeded do 26 | @moduledoc false 27 | 28 | @type t :: struct() 29 | 30 | defstruct [:activity, :scope, :result] 31 | end 32 | 33 | defmodule Workflows.Event.ParallelFailed do 34 | @moduledoc false 35 | 36 | @type t :: struct() 37 | 38 | defstruct [:activity, :scope, :error] 39 | end 40 | -------------------------------------------------------------------------------- /lib/workflows/event/pass.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.PassEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.PassExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | -------------------------------------------------------------------------------- /lib/workflows/event/succeed.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.SucceedEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.SucceedExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result] 15 | end 16 | -------------------------------------------------------------------------------- /lib/workflows/event/task.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.TaskEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.TaskExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | 17 | defmodule Workflows.Event.TaskStarted do 18 | @moduledoc false 19 | 20 | @type t :: struct() 21 | 22 | defstruct [:activity, :scope, :resource, :args] 23 | end 24 | 25 | defmodule Workflows.Event.TaskRetried do 26 | @moduledoc false 27 | 28 | @type t :: struct() 29 | 30 | defstruct [:activity, :scope, :resource, :args, :error, :wait] 31 | end 32 | 33 | defmodule Workflows.Event.TaskSucceeded do 34 | @moduledoc false 35 | 36 | @type t :: struct() 37 | 38 | defstruct [:activity, :scope, :result] 39 | end 40 | 41 | defmodule Workflows.Event.TaskFailed do 42 | @moduledoc false 43 | 44 | @type t :: struct() 45 | 46 | defstruct [:activity, :scope, :error] 47 | end 48 | -------------------------------------------------------------------------------- /lib/workflows/event/wait.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Event.WaitEntered do 2 | @moduledoc false 3 | 4 | @type t :: struct() 5 | 6 | defstruct [:activity, :scope, :args] 7 | end 8 | 9 | defmodule Workflows.Event.WaitExited do 10 | @moduledoc false 11 | 12 | @type t :: struct() 13 | 14 | defstruct [:activity, :scope, :result, :transition] 15 | end 16 | 17 | defmodule Workflows.Event.WaitStarted do 18 | @moduledoc false 19 | 20 | @type t :: struct() 21 | 22 | defstruct [:activity, :scope, :wait] 23 | end 24 | 25 | defmodule Workflows.Event.WaitSucceeded do 26 | @moduledoc false 27 | 28 | @type t :: struct() 29 | 30 | defstruct [:activity, :scope] 31 | end 32 | -------------------------------------------------------------------------------- /lib/workflows/execution.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Execution do 2 | @moduledoc false 3 | 4 | alias Workflows.{Activity, Command, Event, State, Workflow} 5 | 6 | @type t :: %__MODULE__{ 7 | workflow: Workflow.t(), 8 | state: State.t(), 9 | ctx: Activity.ctx() 10 | } 11 | 12 | @type scope :: 13 | {:branch, pos_integer()} 14 | | {:item, pos_integer()} 15 | 16 | @type execution_result :: 17 | {:continue, t(), list(Event.t())} | {:succeed, Activity.args(), list(Event.t())} 18 | 19 | defstruct [:workflow, :ctx, :state] 20 | 21 | @spec start(Workflow.t(), Activity.ctx(), Activity.args()) :: 22 | execution_result() | {:error, term()} 23 | def start(workflow, ctx, args) do 24 | with {:ok, execution} <- create(workflow, ctx, args) do 25 | started = %Event.ExecutionStarted{args: args, ctx: ctx} 26 | do_resume(execution, [started]) 27 | end 28 | end 29 | 30 | @spec resume(t(), Command.t()) :: execution_result() | {:error, term()} 31 | def resume(execution, cmd) do 32 | do_resume(execution, cmd, []) 33 | end 34 | 35 | @spec recover(Workflow.t(), list(Event.t())) :: execution_result() | {:error, term()} 36 | def recover(workflow, events) do 37 | do_recover(workflow, events) 38 | end 39 | 40 | ## Private 41 | defp create(workflow, ctx, args) do 42 | with {:ok, starting} <- Workflow.starting_activity(workflow) do 43 | state = State.create(starting, args) 44 | 45 | execution = %__MODULE__{ 46 | workflow: workflow, 47 | state: state, 48 | ctx: ctx 49 | } 50 | 51 | {:ok, execution} 52 | end 53 | end 54 | 55 | defp update_state(execution, new_state) do 56 | %__MODULE__{execution | state: new_state} 57 | end 58 | 59 | defp do_resume(execution, cmd, events_acc) do 60 | execute_result = Workflow.execute(execution.workflow, execution.state, execution.ctx, cmd) 61 | continue_resume(execution, events_acc, execute_result) 62 | end 63 | 64 | defp do_resume(execution, events_acc) do 65 | execute_result = Workflow.execute(execution.workflow, execution.state, execution.ctx) 66 | continue_resume(execution, events_acc, execute_result) 67 | end 68 | 69 | defp continue_resume(execution, events_acc, execute_result) do 70 | case execute_result do 71 | {:ok, :no_event} -> 72 | {:continue, execution, Enum.reverse(events_acc)} 73 | 74 | {:ok, event} -> 75 | case Workflow.project(execution.workflow, execution.state, event) do 76 | {:continue, new_state} -> 77 | new_execution = update_state(execution, new_state) 78 | do_resume(new_execution, [event | events_acc]) 79 | 80 | {:succeed, result} -> 81 | {:succeed, result, Enum.reverse([event | events_acc])} 82 | end 83 | end 84 | end 85 | 86 | defp do_recover(workflow, [%Event.ExecutionStarted{args: args, ctx: ctx} | events]) do 87 | with {:ok, execution} <- create(workflow, ctx, args) do 88 | case Workflow.project(execution.workflow, execution.state, events) do 89 | {:continue, new_state} -> 90 | new_execution = update_state(execution, new_state) 91 | do_resume(new_execution, []) 92 | 93 | {:succeed, result} -> 94 | {:succeed, result, []} 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/workflows/intrinsic.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Intrinsic do 2 | @moduledoc """ 3 | Intrinsic functions used to process payload template data. 4 | """ 5 | alias Workflows.Activity 6 | 7 | @opaque t :: %__MODULE__{ 8 | name: String.t(), 9 | args: any() 10 | } 11 | 12 | defstruct [:name, :args] 13 | 14 | @spec parse(String.t()) :: {:ok, t()} | {:error, term()} 15 | def parse(_definition) do 16 | {:error, :not_implemented} 17 | end 18 | 19 | @spec apply(String.t(), Activity.ctx(), Activity.args()) :: 20 | {:ok, Activity.args()} | {:error, term()} 21 | def apply(_definition, _ctx, _args) do 22 | {:error, :not_implemented} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/workflows/path.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Path do 2 | @moduledoc """ 3 | A Path is used to query json objects. 4 | """ 5 | alias Workflows.Activity 6 | 7 | @opaque t :: %__MODULE__{ 8 | inner: term() 9 | } 10 | 11 | defstruct [:inner] 12 | 13 | @spec create(String.t()) :: {:ok, t()} | {:error, term()} 14 | def create(path) do 15 | with {:ok, inner} <- Warpath.Expression.compile(path) do 16 | {:ok, %__MODULE__{inner: inner}} 17 | end 18 | end 19 | 20 | @spec query(t(), Activity.args()) :: {:ok, Activity.args()} | {:error, term()} 21 | def query(%__MODULE__{inner: inner}, data) do 22 | Warpath.query(data, inner) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/workflows/payload_template.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.PayloadTemplate do 2 | @moduledoc """ 3 | A PayloadTemplate is used to create new Json objects by combining other objects. 4 | """ 5 | alias Workflows.Activity 6 | alias Workflows.Intrinsic 7 | 8 | @opaque t :: %__MODULE__{ 9 | template: map() 10 | } 11 | 12 | defstruct [:template] 13 | 14 | @doc """ 15 | Create a payload template. 16 | """ 17 | @spec create(map()) :: {:ok, t()} | {:error, term()} 18 | def create(template) when is_map(template) do 19 | # TODO: validate payload template 20 | {:ok, %__MODULE__{template: template}} 21 | end 22 | 23 | def create(_template) do 24 | {:error, :invalid_template} 25 | end 26 | 27 | @doc """ 28 | Apply payload template to arguments, returning a new, transformed output. 29 | """ 30 | @spec apply(t() | nil, Activity.ctx(), Activity.args()) :: 31 | {:ok, Activity.args()} | {:error, term()} 32 | def apply(%__MODULE__{template: template}, ctx, args) do 33 | do_apply(template, ctx, args, []) 34 | end 35 | 36 | def apply(nil, _ctx, args) do 37 | {:ok, args} 38 | end 39 | 40 | ## Private 41 | 42 | defp do_apply(template, ctx, args, acc) when is_map(template) do 43 | template 44 | |> Map.to_list() 45 | |> do_apply(ctx, args, acc) 46 | end 47 | 48 | defp do_apply([{key, value} | template], ctx, args, acc) when is_map(value) do 49 | with {:ok, value} <- do_apply(value, ctx, args, []) do 50 | do_apply(template, ctx, args, [{key, value} | acc]) 51 | end 52 | end 53 | 54 | defp do_apply([], _ctx, _args, acc) do 55 | {:ok, Map.new(acc)} 56 | end 57 | 58 | defp do_apply([{key, value} | template], ctx, args, acc) do 59 | if String.ends_with?(key, ".$") do 60 | with {:ok, value} <- transform_value(value, ctx, args) do 61 | # remove .$ 62 | key = String.slice(key, 0..-3) 63 | do_apply(template, ctx, args, [{key, value} | acc]) 64 | end 65 | else 66 | do_apply(template, ctx, args, [{key, value} | acc]) 67 | end 68 | end 69 | 70 | defp transform_value(value, ctx, args) do 71 | cond do 72 | String.starts_with?(value, "$$") -> 73 | # JsonPath applied to ctx 74 | # remove extra $ 75 | value = String.slice(value, 1..-1) 76 | apply_path(ctx, value) 77 | 78 | String.starts_with?(value, "$") -> 79 | # JsonPath applied to args 80 | apply_path(args, value) 81 | 82 | true -> 83 | # Intrinsic function 84 | Intrinsic.apply(value, ctx, args) 85 | end 86 | end 87 | 88 | defp apply_path(args, path) do 89 | case Warpath.query(args, path, result_type: :value_path) do 90 | {:ok, {nil, ""}} -> 91 | {:error, "States.ParameterPathFailure"} 92 | 93 | {:ok, {value, _}} -> 94 | {:ok, value} 95 | 96 | {:ok, values} when is_list(values) -> 97 | has_unmatched? = 98 | Enum.any?(values, fn 99 | {_, ""} -> true 100 | _ -> false 101 | end) 102 | 103 | if has_unmatched? do 104 | {:error, "States.ParameterPathFailure"} 105 | else 106 | values = Enum.map(values, fn {value, _} -> value end) 107 | {:ok, values} 108 | end 109 | 110 | {:error, _} -> 111 | {:error, "Invalid JsonPath"} 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/workflows/reference_path.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.ReferencePath do 2 | @moduledoc """ 3 | A Reference Path is a Path with the syntax limited to identify a single node. 4 | """ 5 | 6 | @opaque t :: %__MODULE__{ 7 | inner: term() 8 | } 9 | 10 | defstruct [:inner] 11 | 12 | @doc """ 13 | Create a new ReferencePath. 14 | """ 15 | @spec create(String.t()) :: {:ok, t()} | {:error, term()} 16 | def create(path) do 17 | with {:ok, inner} <- Warpath.Expression.compile(path) do 18 | {:ok, %__MODULE__{inner: inner}} 19 | end 20 | end 21 | 22 | @spec apply(t(), map(), map()) :: {:ok, term()} | {:error, term()} 23 | def apply(%__MODULE__{inner: inner}, document, _args) do 24 | case inner.tokens do 25 | [root: "$"] -> {:ok, document} 26 | _ -> {:error, :not_implemented} 27 | end 28 | end 29 | 30 | @spec query(t(), map()) :: {:ok, term()} | {:error, term()} 31 | def query(%__MODULE__{inner: inner}, document) do 32 | case Warpath.query(document, inner, result_type: :value_path_tokens) do 33 | {:ok, {value, _}} -> {:ok, value} 34 | {:ok, [_]} -> {:error, :invalid_reference_path} 35 | error -> error 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/workflows/retrier.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Retrier do 2 | @moduledoc """ 3 | Implements a state retrier. 4 | 5 | ## References 6 | 7 | * https://states-language.net/#errors 8 | """ 9 | 10 | alias Workflows.Error 11 | 12 | @type t :: %__MODULE__{ 13 | error_equals: String.t(), 14 | interval_seconds: pos_integer(), 15 | max_attempts: non_neg_integer(), 16 | backoff_rate: float() 17 | } 18 | 19 | defstruct [:error_equals, :interval_seconds, :max_attempts, :backoff_rate] 20 | 21 | @default_interval_seconds 1 22 | @default_max_attempts 3 23 | @default_backoff_rate 2.0 24 | 25 | @doc """ 26 | Create a new Retrier. 27 | """ 28 | @spec create(any()) :: {:ok, t()} | {:error, term()} 29 | def create(%{"ErrorEquals" => errors} = attrs) do 30 | interval_seconds = Map.get(attrs, "IntervalSeconds", @default_interval_seconds) 31 | max_attempts = Map.get(attrs, "MaxAttempts", @default_max_attempts) 32 | backoff_rate = Map.get(attrs, "BackoffRate", @default_backoff_rate) 33 | do_create(errors, interval_seconds, max_attempts, backoff_rate) 34 | end 35 | 36 | def create(_attrs) do 37 | {:error, :missing_error_equals} 38 | end 39 | 40 | @spec matches?(t(), Error.t()) :: boolean() 41 | def matches?(retrier, error) do 42 | Enum.any?(retrier.error_equals, fn ee -> ee == "States.ALL" || ee == error.name end) 43 | end 44 | 45 | @spec wait_seconds(t(), pos_integer()) :: float() 46 | def wait_seconds(retrier, retry_count) do 47 | retrier.interval_seconds + retry_count * retrier.backoff_rate 48 | end 49 | 50 | ## Private 51 | 52 | defp do_create([], _interval_seconds, _max_attempts, _backoff_rate), 53 | do: {:error, :empty_errors} 54 | 55 | defp do_create(_errors, interval_seconds, _max_attempts, _backoff_rate) 56 | when not is_integer(interval_seconds) 57 | when interval_seconds <= 0, 58 | do: {:error, :invalid_interval_seconds} 59 | 60 | defp do_create(_errors, _interval_seconds, max_attempts, _backoff_rate) 61 | when not is_integer(max_attempts) 62 | when max_attempts < 0, 63 | do: {:error, :invalid_max_attempts} 64 | 65 | defp do_create(_errors, _interval_seconds, _max_attempts, backoff_rate) 66 | when backoff_rate < 1.0, 67 | do: {:error, :invalid_backoff_rate} 68 | 69 | defp do_create(errors, interval_seconds, max_attempts, backoff_rate) do 70 | retrier = %__MODULE__{ 71 | error_equals: errors, 72 | interval_seconds: interval_seconds, 73 | max_attempts: max_attempts, 74 | backoff_rate: backoff_rate 75 | } 76 | 77 | {:ok, retrier} 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/workflows/rule.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Rule do 2 | @moduledoc """ 3 | Choice state rule. 4 | """ 5 | 6 | @type t :: %__MODULE__{ 7 | next: String.t(), 8 | rule: (map() -> boolean()) 9 | } 10 | 11 | defstruct [:next, :rule] 12 | 13 | @doc """ 14 | Create a rule that can be matched on an input. 15 | """ 16 | def create(%{"Next" => next} = rule) do 17 | case do_create(rule) do 18 | {:ok, rule} -> 19 | {:ok, %__MODULE__{next: next, rule: rule}} 20 | 21 | err -> 22 | err 23 | end 24 | end 25 | 26 | def create(_rule) do 27 | {:error, :missing_next} 28 | end 29 | 30 | def call(%__MODULE__{rule: rule}, args) do 31 | rule.(args) 32 | end 33 | 34 | ## Private 35 | 36 | defp do_create(%{"Not" => inner_case}) do 37 | with {:ok, inner_rule} <- do_create(inner_case) do 38 | rule = fn args -> 39 | not inner_rule.(args) 40 | end 41 | 42 | {:ok, rule} 43 | end 44 | end 45 | 46 | defp do_create(%{"Or" => cases}) do 47 | with {:ok, inner_rules} <- do_create_cases(cases) do 48 | rule = fn args -> 49 | Enum.any?(inner_rules, fn rule -> rule.(args) end) 50 | end 51 | 52 | {:ok, rule} 53 | end 54 | end 55 | 56 | defp do_create(%{"And" => cases}) do 57 | with {:ok, inner_rules} <- do_create_cases(cases) do 58 | rule = fn args -> 59 | Enum.all?(inner_rules, fn rule -> rule.(args) end) 60 | end 61 | 62 | {:ok, rule} 63 | end 64 | end 65 | 66 | defp do_create(%{"StringEquals" => value, "Variable" => variable}), 67 | do: compare_with_value(&==/2, &is_binary/1, variable, value) 68 | 69 | defp do_create(%{"StringEqualsPath" => value, "Variable" => variable}), 70 | do: compare_with_path_value(&==/2, &is_binary/1, variable, value) 71 | 72 | defp do_create(%{"StringLessThan" => value, "Variable" => variable}), 73 | do: compare_with_value(& value, "Variable" => variable}), 76 | do: compare_with_path_value(& value, "Variable" => variable}), 79 | do: compare_with_value(&>/2, &is_binary/1, variable, value) 80 | 81 | defp do_create(%{"StringGreaterThanPath" => value, "Variable" => variable}), 82 | do: compare_with_path_value(&>/2, &is_binary/1, variable, value) 83 | 84 | defp do_create(%{"StringLessThanEquals" => value, "Variable" => variable}), 85 | do: compare_with_value(&<=/2, &is_binary/1, variable, value) 86 | 87 | defp do_create(%{"StringLessThanEqualsPath" => value, "Variable" => variable}), 88 | do: compare_with_path_value(&<=/2, &is_binary/1, variable, value) 89 | 90 | defp do_create(%{"StringGreaterThanEquals" => value, "Variable" => variable}), 91 | do: compare_with_value(&>=/2, &is_binary/1, variable, value) 92 | 93 | defp do_create(%{"StringGreaterThanEqualsPath" => value, "Variable" => variable}), 94 | do: compare_with_path_value(&>=/2, &is_binary/1, variable, value) 95 | 96 | defp do_create(%{"StringMatches" => _value, "Variable" => _variable}), 97 | do: {:error, "Not implemented"} 98 | 99 | defp do_create(%{"NumericEquals" => value, "Variable" => variable}), 100 | do: compare_with_value(&==/2, &is_number/1, variable, value) 101 | 102 | defp do_create(%{"NumericEqualsPath" => value, "Variable" => variable}), 103 | do: compare_with_path_value(&==/2, &is_number/1, variable, value) 104 | 105 | defp do_create(%{"NumericLessThan" => value, "Variable" => variable}), 106 | do: compare_with_value(& value, "Variable" => variable}), 109 | do: compare_with_path_value(& value, "Variable" => variable}), 112 | do: compare_with_value(&>/2, &is_number/1, variable, value) 113 | 114 | defp do_create(%{"NumericGreaterThanPath" => value, "Variable" => variable}), 115 | do: compare_with_path_value(&>/2, &is_number/1, variable, value) 116 | 117 | defp do_create(%{"NumericLessThanEquals" => value, "Variable" => variable}), 118 | do: compare_with_value(&<=/2, &is_number/1, variable, value) 119 | 120 | defp do_create(%{"NumericLessThanEqualsPath" => value, "Variable" => variable}), 121 | do: compare_with_path_value(&<=/2, &is_number/1, variable, value) 122 | 123 | defp do_create(%{"NumericGreaterThanEquals" => value, "Variable" => variable}), 124 | do: compare_with_value(&>=/2, &is_number/1, variable, value) 125 | 126 | defp do_create(%{"NumericGreaterThanEqualsPath" => value, "Variable" => variable}), 127 | do: compare_with_path_value(&>=/2, &is_number/1, variable, value) 128 | 129 | defp do_create(%{"BooleanEquals" => value, "Variable" => variable}), 130 | do: compare_with_value(&==/2, &is_boolean/1, variable, value) 131 | 132 | defp do_create(%{"BooleanEqualsPath" => value, "Variable" => variable}), 133 | do: compare_with_path_value(&==/2, &is_boolean/1, variable, value) 134 | 135 | defp do_create(%{"TimestampEquals" => value, "Variable" => variable}), 136 | do: compare_with_value(×tamp_eq/2, &is_timestamp/1, variable, value) 137 | 138 | defp do_create(%{"TimestampEqualsPath" => value, "Variable" => variable}), 139 | do: compare_with_path_value(×tamp_eq/2, &is_timestamp/1, variable, value) 140 | 141 | defp do_create(%{"TimestampLessThan" => value, "Variable" => variable}), 142 | do: compare_with_value(×tamp_lt/2, &is_timestamp/1, variable, value) 143 | 144 | defp do_create(%{"TimestampLessThanPath" => value, "Variable" => variable}), 145 | do: compare_with_path_value(×tamp_lt/2, &is_timestamp/1, variable, value) 146 | 147 | defp do_create(%{"TimestampGreaterThan" => value, "Variable" => variable}), 148 | do: compare_with_value(×tamp_gt/2, &is_timestamp/1, variable, value) 149 | 150 | defp do_create(%{"TimestampGreaterThanPath" => value, "Variable" => variable}), 151 | do: compare_with_path_value(×tamp_gt/2, &is_timestamp/1, variable, value) 152 | 153 | defp do_create(%{"TimestampLessThanEquals" => value, "Variable" => variable}), 154 | do: compare_with_value(×tamp_lte/2, &is_timestamp/1, variable, value) 155 | 156 | defp do_create(%{"TimestampLessThanEqualsPath" => value, "Variable" => variable}), 157 | do: compare_with_path_value(×tamp_lte/2, &is_timestamp/1, variable, value) 158 | 159 | defp do_create(%{"TimestampGreaterThanEquals" => value, "Variable" => variable}), 160 | do: compare_with_value(×tamp_gte/2, &is_timestamp/1, variable, value) 161 | 162 | defp do_create(%{"TimestampGreaterThanEqualsPath" => value, "Variable" => variable}), 163 | do: compare_with_path_value(×tamp_gte/2, &is_timestamp/1, variable, value) 164 | 165 | defp do_create(%{"IsNull" => true, "Variable" => variable}) do 166 | with {:ok, variable_fn} <- path_value(variable, result_type: :value_path) do 167 | rule = fn args -> 168 | case variable_fn.(args) do 169 | # returned nil because the value is not present 170 | {nil, ""} -> false 171 | # returned nil because the value is null 172 | {nil, _} -> true 173 | # value not null 174 | {_, _} -> false 175 | end 176 | end 177 | 178 | {:ok, rule} 179 | end 180 | end 181 | 182 | defp do_create(%{"IsPresent" => true, "Variable" => variable}) do 183 | with {:ok, variable_fn} <- path_value(variable, result_type: :path) do 184 | rule = fn args -> 185 | case variable_fn.(args) do 186 | "" -> false 187 | _ -> true 188 | end 189 | end 190 | 191 | {:ok, rule} 192 | end 193 | end 194 | 195 | defp do_create(%{"IsNumeric" => true, "Variable" => variable}), 196 | do: is_type(&is_number/1, variable) 197 | 198 | defp do_create(%{"IsString" => true, "Variable" => variable}), 199 | do: is_type(&is_binary/1, variable) 200 | 201 | defp do_create(%{"IsBoolean" => true, "Variable" => variable}), 202 | do: is_type(&is_boolean/1, variable) 203 | 204 | defp do_create(%{"IsTimestamp" => true, "Variable" => variable}), 205 | do: is_type(&is_timestamp/1, variable) 206 | 207 | defp do_create(_rule) do 208 | {:error, :invalid_rule} 209 | end 210 | 211 | defp do_create_cases(cases) when is_list(cases) do 212 | do_create_cases(cases, []) 213 | end 214 | 215 | defp do_create_cases(_cases) do 216 | {:error, :invalid_rule_cases} 217 | end 218 | 219 | defp do_create_cases([], acc), do: {:ok, acc} 220 | 221 | defp do_create_cases([rule | cases], acc) do 222 | case do_create(rule) do 223 | {:ok, rule} -> do_create_cases(cases, [rule | acc]) 224 | err -> err 225 | end 226 | end 227 | 228 | defp compare_with_value(compare, check_type, variable, value) do 229 | with {:ok, variable_fn} <- path_value(variable) do 230 | rule = fn args -> 231 | variable_value = variable_fn.(args) 232 | 233 | check_type.(variable_value) and 234 | check_type.(value) and 235 | compare.(variable_value, value) 236 | end 237 | 238 | {:ok, rule} 239 | end 240 | end 241 | 242 | defp compare_with_path_value(compare, check_type, variable, value) do 243 | with {:ok, variable_fn} <- path_value(variable), 244 | {:ok, value_fn} <- path_value(value) do 245 | rule = fn args -> 246 | variable_value = variable_fn.(args) 247 | value_value = value_fn.(args) 248 | 249 | check_type.(variable_value) and 250 | check_type.(value_value) and 251 | compare.(variable_value, value_value) 252 | end 253 | 254 | {:ok, rule} 255 | end 256 | end 257 | 258 | defp is_type(check_type, variable) do 259 | with {:ok, variable_fn} <- path_value(variable) do 260 | rule = fn args -> 261 | variable_value = variable_fn.(args) 262 | check_type.(variable_value) 263 | end 264 | 265 | {:ok, rule} 266 | end 267 | end 268 | 269 | defp path_value(path, opts \\ []) do 270 | with {:ok, expr} <- Warpath.Expression.compile(path) do 271 | value_fn = fn args -> Warpath.query!(args, expr, opts) end 272 | {:ok, value_fn} 273 | end 274 | end 275 | 276 | defp timestamp_eq(ts1, ts2), 277 | do: timestamp_compare(ts1, ts2) == :eq 278 | 279 | defp timestamp_lt(ts1, ts2), 280 | do: timestamp_compare(ts1, ts2) == :lt 281 | 282 | defp timestamp_gt(ts1, ts2), 283 | do: timestamp_compare(ts1, ts2) == :gt 284 | 285 | defp timestamp_lte(ts1, ts2) do 286 | cmp = timestamp_compare(ts1, ts2) 287 | cmp == :lt || cmp == :eq 288 | end 289 | 290 | defp timestamp_gte(ts1, ts2) do 291 | cmp = timestamp_compare(ts1, ts2) 292 | cmp == :gt || cmp == :eq 293 | end 294 | 295 | defp timestamp_compare(ts1, ts2) do 296 | with {:ok, ts1, _} <- DateTime.from_iso8601(ts1), 297 | {:ok, ts2, _} <- DateTime.from_iso8601(ts2) do 298 | DateTime.compare(ts1, ts2) 299 | else 300 | _ -> :error 301 | end 302 | end 303 | 304 | defp is_timestamp(value) do 305 | case DateTime.from_iso8601(value) do 306 | {:ok, _, _} -> true 307 | _ -> false 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /lib/workflows/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State do 2 | @moduledoc false 3 | 4 | alias Workflows.{Activity, Command, Error, Event, State, StateUtil} 5 | 6 | @type t :: 7 | State.Choice.t() 8 | | State.Fail.t() 9 | | State.Map.t() 10 | | State.Parallel.t() 11 | | State.Pass.t() 12 | | State.Succeed.t() 13 | | State.Task.t() 14 | | State.Wait.t() 15 | 16 | @callback project(state :: t(), activity :: Activity.t(), event :: Event.t()) :: 17 | {:stay, state :: t()} | {:transition, Activity.name(), Activity.args()} 18 | 19 | @type execute_result :: {:ok, Event.maybe()} | {:error, term()} 20 | @type execute_command_result :: {:ok, Event.t()} | {:error, term()} 21 | 22 | @type project_result :: 23 | {:stay, t()} 24 | | {:transition, Activity.transition(), Activity.args()} 25 | | {:succeed, Activity.args()} 26 | | {:fail, Error.t()} 27 | 28 | @spec create(Activity.t(), Activity.args()) :: t() 29 | def create(activity, args) do 30 | StateUtil.create(activity, args) 31 | end 32 | 33 | @spec execute(t(), Activity.t(), Activity.ctx()) :: execute_result() 34 | def execute(state, activity, ctx) do 35 | StateUtil.execute(state, activity, ctx) 36 | end 37 | 38 | @spec execute(t(), Activity.t(), Activity.ctx(), Command.t()) :: execute_command_result() 39 | def execute(state, activity, ctx, cmd) do 40 | StateUtil.execute(state, activity, ctx, cmd) 41 | end 42 | 43 | @spec project(t(), Activity.t(), Event.t()) :: project_result() 44 | def project(state, activity, event) do 45 | StateUtil.project(state, activity, event) 46 | end 47 | 48 | defmacro __using__(_opts) do 49 | quote location: :keep do 50 | defstruct [:activity, :inner] 51 | 52 | alias Workflows.Activity 53 | alias Workflows.State 54 | 55 | @behaviour State 56 | 57 | @type t :: %__MODULE__{ 58 | activity: String.t(), 59 | inner: term() 60 | } 61 | 62 | @spec create(Activity.t(), Activity.args()) :: t() 63 | def create(activity, state_args) do 64 | %__MODULE__{ 65 | activity: activity.name, 66 | inner: {:before_enter, state_args} 67 | } 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/workflows/state/choice.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Choice do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.State 7 | 8 | use State 9 | 10 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 11 | def project(state, _activity, event), do: do_project(state, event) 12 | 13 | ## Private 14 | 15 | defp do_execute(%State.Choice{} = state, %Activity.Choice{} = activity, ctx) do 16 | case state.inner do 17 | {:before_enter, state_args} -> 18 | Activity.enter(activity, ctx, state_args) 19 | 20 | {:running, state_args, effective_args} -> 21 | Activity.exit(activity, ctx, state_args, effective_args) 22 | end 23 | end 24 | 25 | defp do_project(%State.Choice{} = state, %Event.ChoiceEntered{} = event) do 26 | case state.inner do 27 | {:before_enter, state_args} -> 28 | new_state = %State.Choice{state | inner: {:running, state_args, event.args}} 29 | {:stay, new_state} 30 | 31 | _ -> 32 | {:error, :invalid_event, event} 33 | end 34 | end 35 | 36 | defp do_project(%State.Choice{} = state, %Event.ChoiceExited{} = event) do 37 | case state.inner do 38 | {:running, _state_args, _args} -> 39 | {:transition, event.transition, event.result} 40 | 41 | _ -> 42 | {:error, :invalid_event, event} 43 | end 44 | end 45 | 46 | defp do_project(_state, event) do 47 | {:error, :invalid_event, event} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/workflows/state/fail.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Fail do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.State 7 | 8 | use State 9 | 10 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 11 | def project(state, _activity, event), do: do_project(state, event) 12 | 13 | ## Private 14 | 15 | defp do_execute(%State.Fail{} = state, %Activity.Fail{} = activity, ctx) do 16 | case state.inner do 17 | {:before_enter, state_args} -> 18 | Activity.enter(activity, ctx, state_args) 19 | 20 | {:running, state_args, effective_args} -> 21 | Activity.exit(activity, ctx, state_args, effective_args) 22 | end 23 | end 24 | 25 | defp do_project(%State.Fail{} = state, %Event.FailEntered{} = event) do 26 | case state.inner do 27 | {:before_enter, state_args} -> 28 | new_state = %State.Fail{state | inner: {:running, state_args, event.args}} 29 | {:stay, new_state} 30 | 31 | _ -> 32 | {:error, :invalid_event, event} 33 | end 34 | end 35 | 36 | defp do_project(%State.Fail{} = state, %Event.FailExited{} = event) do 37 | case state.inner do 38 | {:running, _state_args, _args} -> 39 | {:fail, event.error} 40 | 41 | _ -> 42 | {:error, :invalid_event, event} 43 | end 44 | end 45 | 46 | defp do_project(_state, event) do 47 | {:error, :invalid_event, event} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/workflows/state/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Map do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Command 6 | alias Workflows.Event 7 | alias Workflows.State 8 | alias Workflows.Workflow 9 | 10 | use State 11 | 12 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 13 | def execute(state, activity, ctx, cmd), do: do_execute_command(state, activity, ctx, cmd) 14 | def project(state, activity, event), do: do_project(state, activity, event) 15 | 16 | ## Private 17 | 18 | defp do_execute(%State.Map{} = state, %Activity.Map{} = activity, ctx) do 19 | case state.inner do 20 | {:before_enter, state_args} -> 21 | Activity.enter(activity, ctx, state_args) 22 | 23 | {:starting, _state_args, effective_args} -> 24 | Activity.Map.start_map(activity, ctx, effective_args) 25 | 26 | {:running, _state_args, _effective_args, children} -> 27 | case execute_child(activity.iterator, children, activity.max_concurrency, 0, 0, ctx) do 28 | {:ok, :no_event} -> 29 | check_all_children_completed(activity, ctx, children) 30 | 31 | {:ok, event} -> 32 | {:ok, event} 33 | end 34 | 35 | {:map_finished, state_args, result} -> 36 | Activity.exit(activity, ctx, state_args, result) 37 | end 38 | end 39 | 40 | defp do_execute_command(%State.Map{} = state, %Activity.Map{} = activity, ctx, cmd) do 41 | case state.inner do 42 | {:running, _state_args, _effective_args, children} -> 43 | case Command.pop_scope(cmd) do 44 | {{:item, item_index}, cmd} -> 45 | child_state = Enum.at(children, item_index) 46 | 47 | case child_state do 48 | {:continue, child_state} -> 49 | with {:ok, event} <- Workflow.execute(activity.iterator, child_state, ctx, cmd) do 50 | { 51 | :ok, 52 | event 53 | |> Event.push_scope({:item, item_index}) 54 | } 55 | end 56 | 57 | _ -> 58 | {:error, :invalid_command, cmd} 59 | end 60 | 61 | _ -> 62 | {:error, :invalid_command, cmd} 63 | end 64 | 65 | _ -> 66 | {:error, :invalid_command, cmd} 67 | end 68 | end 69 | 70 | defp do_project(%State.Map{} = state, activity, event) do 71 | case event.scope do 72 | [] -> do_project_this_scope(state, activity, event) 73 | _ -> do_project_scoped(state, activity, event) 74 | end 75 | end 76 | 77 | defp do_project_this_scope( 78 | %State.Map{} = state, 79 | _activity, 80 | %Event.MapEntered{} = event 81 | ) do 82 | case state.inner do 83 | {:before_enter, state_args} -> 84 | new_state = %State.Map{state | inner: {:starting, state_args, event.args}} 85 | {:stay, new_state} 86 | 87 | _ -> 88 | {:error, :invalid_event, event} 89 | end 90 | end 91 | 92 | defp do_project_this_scope( 93 | %State.Map{} = state, 94 | activity, 95 | %Event.MapStarted{} = event 96 | ) do 97 | case state.inner do 98 | {:starting, state_args, effective_args} -> 99 | with {:ok, children} <- create_children_starting_state(activity.iterator, effective_args) do 100 | new_state = %State.Map{ 101 | state 102 | | inner: {:running, state_args, effective_args, children} 103 | } 104 | 105 | {:stay, new_state} 106 | end 107 | 108 | _ -> 109 | {:error, :invalid_event, event} 110 | end 111 | end 112 | 113 | defp do_project_this_scope( 114 | %State.Map{} = state, 115 | _activity, 116 | %Event.MapSucceeded{} = event 117 | ) do 118 | case state.inner do 119 | {:running, state_args, _, _children} -> 120 | new_state = %State.Map{state | inner: {:map_finished, state_args, event.result}} 121 | {:stay, new_state} 122 | 123 | _ -> 124 | {:error, :invalid_event, event} 125 | end 126 | end 127 | 128 | defp do_project_this_scope( 129 | %State.Map{} = state, 130 | _activity, 131 | %Event.MapExited{} = event 132 | ) do 133 | case state.inner do 134 | {:map_finished, _state_args, _args} -> 135 | {:transition, event.transition, event.result} 136 | 137 | _ -> 138 | {:error, :invalid_event, event} 139 | end 140 | end 141 | 142 | defp do_project_this_scope(_state, _activity, event) do 143 | {:error, :invalid_event, event} 144 | end 145 | 146 | defp do_project_scoped(%State.Map{} = state, activity, event) do 147 | case state.inner do 148 | {:running, state_args, effective_args, children} -> 149 | case Event.pop_scope(event) do 150 | {{:item, item_index}, event} -> 151 | child_state = Enum.at(children, item_index) 152 | 153 | case child_state do 154 | {:continue, child_state} -> 155 | new_child_state = project_child(child_state, activity.iterator, event) 156 | new_children = List.replace_at(children, item_index, new_child_state) 157 | 158 | new_state = %State.Map{ 159 | state 160 | | inner: {:running, state_args, effective_args, new_children} 161 | } 162 | 163 | {:stay, new_state} 164 | 165 | _ -> 166 | # Wrong item index 167 | {:error, :invalid_event, event} 168 | end 169 | 170 | {_, _} -> 171 | {:error, :invalid_event, event} 172 | end 173 | 174 | _ -> 175 | {:error, :invalid_event, event} 176 | end 177 | end 178 | 179 | defp create_children_starting_state(iterator, args) do 180 | create_children_starting_state(iterator, args, []) 181 | end 182 | 183 | defp create_children_starting_state(_iterator, [], children) do 184 | {:ok, Enum.reverse(children)} 185 | end 186 | 187 | defp create_children_starting_state(iterator, [args | rest], children) do 188 | with {:ok, activity} <- Workflow.starting_activity(iterator) do 189 | state = State.create(activity, args) 190 | create_children_starting_state(iterator, rest, [{:continue, state} | children]) 191 | end 192 | end 193 | 194 | # Finished executing all children 195 | defp execute_child(_iterator, [], _max_concurrency, _num_running, _item_index, _ctx), 196 | do: {:ok, :no_event} 197 | 198 | defp execute_child( 199 | iterator, 200 | [{:continue, child} | children], 201 | max_concurrency, 202 | num_running, 203 | item_index, 204 | ctx 205 | ) do 206 | if max_concurrency > 0 and max_concurrency <= num_running do 207 | {:ok, :no_event} 208 | else 209 | with {:ok, activity} <- Workflow.activity(iterator, child.activity), 210 | {:ok, event} <- State.execute(child, activity, ctx) do 211 | case event do 212 | :no_event -> 213 | execute_child( 214 | iterator, 215 | children, 216 | max_concurrency, 217 | num_running + 1, 218 | item_index + 1, 219 | ctx 220 | ) 221 | 222 | event -> 223 | { 224 | :ok, 225 | event 226 | |> Event.push_scope({:item, item_index}) 227 | } 228 | end 229 | end 230 | end 231 | end 232 | 233 | defp execute_child( 234 | iterator, 235 | [{:succeed, _result} | children], 236 | max_concurrency, 237 | num_running, 238 | item_index, 239 | ctx 240 | ) do 241 | # Succeeded items don't count towards the number of concurrently running tasks 242 | execute_child(iterator, children, max_concurrency, num_running, item_index + 1, ctx) 243 | end 244 | 245 | defp project_child(child_state, iterator, event) do 246 | Workflow.project(iterator, child_state, event) 247 | end 248 | 249 | defp check_all_children_completed(activity, ctx, children) do 250 | check_all_children_completed(activity, ctx, children, []) 251 | end 252 | 253 | defp check_all_children_completed(activity, ctx, [], result) do 254 | Activity.Map.complete_map(activity, ctx, result) 255 | end 256 | 257 | defp check_all_children_completed(_activity, _ctx, [{:continue, _} | _children], _result) do 258 | # Still running, waiting for command 259 | {:ok, :no_event} 260 | end 261 | 262 | defp check_all_children_completed(activity, ctx, [{:succeed, r} | children], result) do 263 | check_all_children_completed(activity, ctx, children, [r | result]) 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /lib/workflows/state/parallel.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Parallel do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Command 6 | alias Workflows.Event 7 | alias Workflows.State 8 | alias Workflows.Workflow 9 | 10 | use State 11 | 12 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 13 | def execute(state, activity, ctx, cmd), do: do_execute_command(state, activity, ctx, cmd) 14 | def project(state, activity, event), do: do_project(state, activity, event) 15 | 16 | ## Private 17 | 18 | defp do_execute(%State.Parallel{} = state, %Activity.Parallel{} = activity, ctx) do 19 | case state.inner do 20 | {:before_enter, state_args} -> 21 | Activity.enter(activity, ctx, state_args) 22 | 23 | {:starting, _state_args, effective_args} -> 24 | Activity.Parallel.start_parallel(activity, ctx, effective_args) 25 | 26 | {:running, _state_args, _effective_args, children} -> 27 | case execute_child(activity.branches, children, 0, ctx) do 28 | {:ok, :no_event} -> 29 | check_all_children_completed(activity, ctx, children) 30 | 31 | {:ok, event} -> 32 | {:ok, event} 33 | end 34 | 35 | {:parallel_finished, state_args, result} -> 36 | Activity.exit(activity, ctx, state_args, result) 37 | end 38 | end 39 | 40 | defp do_execute_command(%State.Parallel{} = state, %Activity.Parallel{} = activity, ctx, cmd) do 41 | case state.inner do 42 | {:running, _state_args, _effective_args, children} -> 43 | case Command.pop_scope(cmd) do 44 | {{:branch, branch_index}, cmd} -> 45 | child_state = 46 | Enum.zip(activity.branches, children) 47 | |> Enum.at(branch_index) 48 | 49 | case child_state do 50 | {branch, {:continue, child_state}} -> 51 | with {:ok, event} <- Workflow.execute(branch, child_state, ctx, cmd) do 52 | {:ok, event |> Event.push_scope({:branch, branch_index})} 53 | end 54 | 55 | _ -> 56 | {:error, :invalid_command, cmd} 57 | end 58 | 59 | _ -> 60 | {:error, :invalid_command, cmd} 61 | end 62 | 63 | _ -> 64 | {:error, :invalid_command, cmd} 65 | end 66 | end 67 | 68 | defp do_project(%State.Parallel{} = state, activity, event) do 69 | case event.scope do 70 | [] -> do_project_this_scope(state, activity, event) 71 | _ -> do_project_scoped(state, activity, event) 72 | end 73 | end 74 | 75 | defp do_project_this_scope( 76 | %State.Parallel{} = state, 77 | _activity, 78 | %Event.ParallelEntered{} = event 79 | ) do 80 | case state.inner do 81 | {:before_enter, state_args} -> 82 | new_state = %State.Parallel{state | inner: {:starting, state_args, event.args}} 83 | {:stay, new_state} 84 | 85 | _ -> 86 | {:error, :invalid_event, event} 87 | end 88 | end 89 | 90 | defp do_project_this_scope( 91 | %State.Parallel{} = state, 92 | activity, 93 | %Event.ParallelStarted{} = event 94 | ) do 95 | case state.inner do 96 | {:starting, state_args, effective_args} -> 97 | with {:ok, children} <- create_children_starting_state(activity.branches, effective_args) do 98 | new_state = %State.Parallel{ 99 | state 100 | | inner: {:running, state_args, effective_args, children} 101 | } 102 | 103 | {:stay, new_state} 104 | end 105 | 106 | _ -> 107 | {:error, :invalid_event, event} 108 | end 109 | end 110 | 111 | defp do_project_this_scope( 112 | %State.Parallel{} = state, 113 | _activity, 114 | %Event.ParallelSucceeded{} = event 115 | ) do 116 | case state.inner do 117 | {:running, state_args, _, _children} -> 118 | new_state = %State.Parallel{state | inner: {:parallel_finished, state_args, event.result}} 119 | {:stay, new_state} 120 | 121 | _ -> 122 | {:error, :invalid_event, event} 123 | end 124 | end 125 | 126 | defp do_project_this_scope( 127 | %State.Parallel{} = state, 128 | _activity, 129 | %Event.ParallelExited{} = event 130 | ) do 131 | case state.inner do 132 | {:parallel_finished, _state_args, _args} -> 133 | {:transition, event.transition, event.result} 134 | 135 | _ -> 136 | {:error, :invalid_event, event} 137 | end 138 | end 139 | 140 | defp do_project_this_scope(_state, _activity, event) do 141 | {:error, :invalid_event, event} 142 | end 143 | 144 | defp do_project_scoped(%State.Parallel{} = state, activity, event) do 145 | case state.inner do 146 | {:running, state_args, effective_args, children} -> 147 | case Event.pop_scope(event) do 148 | {{:branch, branch_index}, event} -> 149 | child_state = 150 | Enum.zip(activity.branches, children) 151 | |> Enum.at(branch_index) 152 | 153 | case child_state do 154 | {branch, {:continue, child_state}} -> 155 | new_child_state = project_child(child_state, branch, event) 156 | new_children = List.replace_at(children, branch_index, new_child_state) 157 | 158 | new_state = %State.Parallel{ 159 | state 160 | | inner: {:running, state_args, effective_args, new_children} 161 | } 162 | 163 | {:stay, new_state} 164 | 165 | _ -> 166 | # Wrong branch index 167 | {:error, :invalid_event, event} 168 | end 169 | 170 | {_, _} -> 171 | {:error, :invalid_event, event} 172 | end 173 | 174 | _ -> 175 | {:error, :invalid_event, event} 176 | end 177 | end 178 | 179 | defp create_children_starting_state(branches, args) do 180 | create_children_starting_state(branches, args, []) 181 | end 182 | 183 | defp create_children_starting_state([], _args, children) do 184 | {:ok, Enum.reverse(children)} 185 | end 186 | 187 | defp create_children_starting_state([branch | branches], args, children) do 188 | with {:ok, activity} <- Workflow.starting_activity(branch) do 189 | state = State.create(activity, args) 190 | create_children_starting_state(branches, args, [{:continue, state} | children]) 191 | end 192 | end 193 | 194 | # Finished executing all children 195 | defp execute_child([], [], _branch_index, _ctx), do: {:ok, :no_event} 196 | 197 | defp execute_child([branch | branches], [{:continue, child} | children], branch_index, ctx) do 198 | with {:ok, activity} <- Workflow.activity(branch, child.activity), 199 | {:ok, event} <- State.execute(child, activity, ctx) do 200 | case event do 201 | :no_event -> 202 | execute_child(branches, children, branch_index + 1, ctx) 203 | 204 | event -> 205 | {:ok, event |> Event.push_scope({:branch, branch_index})} 206 | end 207 | end 208 | end 209 | 210 | defp execute_child([_branch | branches], [{:succeed, _result} | children], branch_index, ctx) do 211 | execute_child(branches, children, branch_index + 1, ctx) 212 | end 213 | 214 | defp project_child(child_state, branch, event) do 215 | Workflow.project(branch, child_state, event) 216 | end 217 | 218 | defp check_all_children_completed(activity, ctx, children) do 219 | check_all_children_completed(activity, ctx, children, []) 220 | end 221 | 222 | defp check_all_children_completed(activity, ctx, [], result) do 223 | Activity.Parallel.complete_parallel(activity, ctx, result) 224 | end 225 | 226 | defp check_all_children_completed(_activity, _ctx, [{:continue, _} | _children], _result) do 227 | # Still running, waiting for command 228 | {:ok, :no_event} 229 | end 230 | 231 | defp check_all_children_completed(activity, ctx, [{:succeed, r} | children], result) do 232 | check_all_children_completed(activity, ctx, children, [r | result]) 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/workflows/state/pass.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Pass do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.State 7 | 8 | use State 9 | 10 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 11 | def project(state, _activity, event), do: do_project(state, event) 12 | 13 | ## Private 14 | 15 | defp do_execute(%State.Pass{} = state, %Activity.Pass{} = activity, ctx) do 16 | case state.inner do 17 | {:before_enter, state_args} -> 18 | Activity.enter(activity, ctx, state_args) 19 | 20 | {:running, state_args, effective_args} -> 21 | Activity.exit(activity, ctx, state_args, effective_args) 22 | end 23 | end 24 | 25 | defp do_project(%State.Pass{} = state, %Event.PassEntered{} = event) do 26 | case state.inner do 27 | {:before_enter, state_args} -> 28 | new_state = %State.Pass{state | inner: {:running, state_args, event.args}} 29 | {:stay, new_state} 30 | 31 | _ -> 32 | {:error, :invalid_event, event} 33 | end 34 | end 35 | 36 | defp do_project(%State.Pass{} = state, %Event.PassExited{} = event) do 37 | case state.inner do 38 | {:running, _state_args, _args} -> 39 | {:transition, event.transition, event.result} 40 | 41 | _ -> 42 | {:error, :invalid_event, event} 43 | end 44 | end 45 | 46 | defp do_project(_state, event) do 47 | {:error, :invalid_event, event} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/workflows/state/succeed.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Succeed do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Event 6 | alias Workflows.State 7 | 8 | use State 9 | 10 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 11 | def project(state, _activity, event), do: do_project(state, event) 12 | 13 | ## Private 14 | 15 | defp do_execute(%State.Succeed{} = state, %Activity.Succeed{} = activity, ctx) do 16 | case state.inner do 17 | {:before_enter, state_args} -> 18 | Activity.enter(activity, ctx, state_args) 19 | 20 | {:running, state_args, effective_args} -> 21 | Activity.exit(activity, ctx, state_args, effective_args) 22 | end 23 | end 24 | 25 | defp do_project(%State.Succeed{} = state, %Event.SucceedEntered{} = event) do 26 | case state.inner do 27 | {:before_enter, state_args} -> 28 | new_state = %State.Succeed{state | inner: {:running, state_args, event.args}} 29 | {:stay, new_state} 30 | 31 | _ -> 32 | {:error, :invalid_event, event} 33 | end 34 | end 35 | 36 | defp do_project(%State.Succeed{} = state, %Event.SucceedExited{} = event) do 37 | case state.inner do 38 | {:running, _state_args, _args} -> 39 | {:succeed, event.result} 40 | 41 | _ -> 42 | {:error, :invalid_event, event} 43 | end 44 | end 45 | 46 | defp do_project(_state, event) do 47 | {:error, :invalid_event, event} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/workflows/state/task.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Task do 2 | @moduledoc false 3 | 4 | alias Workflows.{Activity, Catcher, Command, Event, Retrier, State} 5 | 6 | use State 7 | 8 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 9 | def execute(state, activity, ctx, cmd), do: do_execute_command(state, activity, ctx, cmd) 10 | def project(state, activity, event), do: do_project(state, activity, event) 11 | 12 | ## Private 13 | 14 | defp do_execute(%State.Task{} = state, %Activity.Task{} = activity, ctx) do 15 | case state.inner do 16 | {:before_enter, state_args} -> 17 | Activity.enter(activity, ctx, state_args) 18 | 19 | {:running, _state_args, effective_args} -> 20 | Activity.Task.start_task(activity, ctx, effective_args) 21 | 22 | {:waiting_response, _state_args, _effective_args, _retriers_state} -> 23 | # Need command to move forward 24 | {:ok, :no_event} 25 | 26 | {:task_finished, state_args, result} -> 27 | Activity.exit(activity, ctx, state_args, result) 28 | end 29 | end 30 | 31 | defp do_execute_command( 32 | %State.Task{} = state, 33 | %Activity.Task{} = activity, 34 | ctx, 35 | %Command.CompleteTask{} = cmd 36 | ) do 37 | case state.inner do 38 | {:waiting_response, _state_args, _effective_args, _retriers_state} -> 39 | Activity.Task.complete_task(activity, ctx, cmd.result) 40 | 41 | _ -> 42 | {:error, :invalid_command, cmd} 43 | end 44 | end 45 | 46 | defp do_execute_command( 47 | %State.Task{} = state, 48 | %Activity.Task{} = activity, 49 | ctx, 50 | %Command.FailTask{} = cmd 51 | ) do 52 | case state.inner do 53 | {:waiting_response, _state_args, effective_args, retriers_state} -> 54 | case match_retriers(retriers_state, activity.retry, cmd.error) do 55 | {:retry, retrier, retry_count} -> 56 | wait = Retrier.wait_seconds(retrier, retry_count) 57 | Activity.Task.retry_task(activity, ctx, effective_args, cmd.error, wait) 58 | 59 | :max_attempts_reached -> 60 | Activity.Task.fail_task(activity, ctx, cmd.error) 61 | 62 | :no_match -> 63 | nil 64 | end 65 | 66 | _ -> 67 | {:error, :invalid_command, cmd} 68 | end 69 | end 70 | 71 | defp do_execute_command(%State.Task{}, %Activity.Task{}, _ctx, cmd) do 72 | {:error, :invalid_command, cmd} 73 | end 74 | 75 | defp do_project(%State.Task{} = state, _activity, %Event.TaskEntered{} = event) do 76 | case state.inner do 77 | {:before_enter, state_args} -> 78 | new_state = %State.Task{state | inner: {:running, state_args, event.args}} 79 | {:stay, new_state} 80 | 81 | _ -> 82 | {:error, :invalid_event, event} 83 | end 84 | end 85 | 86 | defp do_project(%State.Task{} = state, activity, %Event.TaskStarted{} = event) do 87 | case state.inner do 88 | {:running, state_args, effective_args} -> 89 | retriers_state = Enum.map(activity.retry, fn _ -> 0 end) 90 | 91 | new_state = %State.Task{ 92 | state 93 | | inner: {:waiting_response, state_args, effective_args, retriers_state} 94 | } 95 | 96 | {:stay, new_state} 97 | 98 | _ -> 99 | {:error, :invalid_event, event} 100 | end 101 | end 102 | 103 | defp do_project(%State.Task{} = state, _activity, %Event.TaskSucceeded{} = event) do 104 | case state.inner do 105 | {:waiting_response, state_args, _effective_args, _retriers_state} -> 106 | new_state = %State.Task{state | inner: {:task_finished, state_args, event.result}} 107 | {:stay, new_state} 108 | 109 | _ -> 110 | {:error, :invalid_event, event} 111 | end 112 | end 113 | 114 | defp do_project(%State.Task{} = state, activity, %Event.TaskRetried{} = event) do 115 | case state.inner do 116 | {:waiting_response, state_args, effective_args, retriers_state} -> 117 | case update_retriers_state(retriers_state, activity.retry, event.error, []) do 118 | {:ok, new_retriers_state} -> 119 | new_inner = {:waiting_response, state_args, effective_args, new_retriers_state} 120 | new_state = %State.Task{state | inner: new_inner} 121 | {:stay, new_state} 122 | 123 | {:error, _reason} -> 124 | {:error, :invalid_event, event} 125 | end 126 | 127 | _ -> 128 | {:error, :invalid_event, event} 129 | end 130 | end 131 | 132 | defp do_project(%State.Task{} = state, activity, %Event.TaskFailed{} = event) do 133 | case state.inner do 134 | {:waiting_response, _state_args, _effective_args, _retriers_state} -> 135 | case Enum.find(activity.catch, fn c -> Catcher.matches?(c, event.error) end) do 136 | nil -> 137 | {:fail, event.error} 138 | 139 | catcher -> 140 | # TODO: add result path here 141 | error = %{"Name" => event.error.name, "Cause" => event.error.cause} 142 | {:transition, {:next, catcher.next}, error} 143 | end 144 | 145 | _ -> 146 | {:error, :invalid_event, event} 147 | end 148 | end 149 | 150 | defp do_project(%State.Task{} = state, _activity, %Event.TaskExited{} = event) do 151 | case state.inner do 152 | {:task_finished, _state_args, _args} -> 153 | {:transition, event.transition, event.result} 154 | 155 | _ -> 156 | {:error, :invalid_event, event} 157 | end 158 | end 159 | 160 | defp do_project(_state, _activity, event) do 161 | {:error, :invalid_event, event} 162 | end 163 | 164 | defp match_retriers([], _retriers, _error) do 165 | :no_match 166 | end 167 | 168 | defp match_retriers([state | states], [retrier | retriers], error) do 169 | if Retrier.matches?(retrier, error) do 170 | if state < retrier.max_attempts do 171 | {:retry, retrier, state} 172 | else 173 | :max_attempts_reached 174 | end 175 | else 176 | match_retriers(states, retriers, error) 177 | end 178 | end 179 | 180 | defp update_retriers_state([], [], _error, _new_states) do 181 | {:error, :expected_matching_retrier} 182 | end 183 | 184 | defp update_retriers_state([state | states], [retrier | retriers], error, new_states) do 185 | if Retrier.matches?(retrier, error) do 186 | new_state = state + 1 187 | new_states = Enum.reverse(new_states) ++ [new_state | states] 188 | {:ok, new_states} 189 | else 190 | update_retriers_state(states, retriers, error, [state | new_states]) 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/workflows/state/wait.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.Wait do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Command 6 | alias Workflows.Event 7 | alias Workflows.State 8 | 9 | use State 10 | 11 | def execute(state, activity, ctx), do: do_execute(state, activity, ctx) 12 | def execute(state, activity, ctx, cmd), do: do_execute_command(state, activity, ctx, cmd) 13 | def project(state, _activity, event), do: do_project(state, event) 14 | 15 | ## Private 16 | 17 | defp do_execute(%State.Wait{} = state, %Activity.Wait{} = activity, ctx) do 18 | case state.inner do 19 | {:before_enter, state_args} -> 20 | Activity.enter(activity, ctx, state_args) 21 | 22 | {:running, _state_args, effective_args} -> 23 | Activity.Wait.start_wait(activity, ctx, effective_args) 24 | 25 | {:waiting, _state_args, _effective_args} -> 26 | # Need command to move forward 27 | {:ok, :no_event} 28 | 29 | {:wait_finished, state_args, effective_args} -> 30 | Activity.exit(activity, ctx, state_args, effective_args) 31 | end 32 | end 33 | 34 | defp do_execute_command( 35 | %State.Wait{} = state, 36 | %Activity.Wait{} = activity, 37 | ctx, 38 | %Command.FinishWaiting{} = cmd 39 | ) do 40 | case state.inner do 41 | {:waiting, _, _} -> 42 | Activity.Wait.finish_waiting(activity, ctx) 43 | 44 | _ -> 45 | {:error, :invalid_command, cmd} 46 | end 47 | end 48 | 49 | defp do_execute_command(%State.Wait{}, %Activity.Wait{}, _ctx, cmd) do 50 | {:error, :invalid_command, cmd} 51 | end 52 | 53 | defp do_project(%State.Wait{} = state, %Event.WaitEntered{} = event) do 54 | case state.inner do 55 | {:before_enter, state_args} -> 56 | new_state = %State.Wait{state | inner: {:running, state_args, event.args}} 57 | {:stay, new_state} 58 | 59 | _ -> 60 | {:error, :invalid_event, event} 61 | end 62 | end 63 | 64 | defp do_project(%State.Wait{} = state, %Event.WaitStarted{} = event) do 65 | case state.inner do 66 | {:running, state_args, effective_args} -> 67 | new_state = %State.Wait{state | inner: {:waiting, state_args, effective_args}} 68 | {:stay, new_state} 69 | 70 | _ -> 71 | {:error, :invalid_event, event} 72 | end 73 | end 74 | 75 | defp do_project(%State.Wait{} = state, %Event.WaitSucceeded{} = event) do 76 | case state.inner do 77 | {:waiting, state_args, effective_args} -> 78 | new_state = %State.Wait{state | inner: {:wait_finished, state_args, effective_args}} 79 | {:stay, new_state} 80 | 81 | _ -> 82 | {:error, :invalid_event, event} 83 | end 84 | end 85 | 86 | defp do_project(%State.Wait{} = state, %Event.WaitExited{} = event) do 87 | case state.inner do 88 | {:wait_finished, _state_args, _args} -> 89 | {:transition, event.transition, event.result} 90 | 91 | _ -> 92 | {:error, :invalid_event, event} 93 | end 94 | end 95 | 96 | defp do_project(_state, event) do 97 | {:error, :invalid_event, event} 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/workflows/state_util.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.StateUtil do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.State 6 | 7 | def create(activity, args) do 8 | do_create(activity, args) 9 | end 10 | 11 | def execute(state, activity, ctx) do 12 | do_execute(state, activity, ctx) 13 | end 14 | 15 | def execute(state, activity, ctx, cmd) do 16 | do_execute_command(state, activity, ctx, cmd) 17 | end 18 | 19 | def project(state, activity, event) do 20 | do_project(state, activity, event) 21 | end 22 | 23 | ## Private 24 | 25 | defp do_create(%Activity.Choice{} = activity, ctx) do 26 | State.Choice.create(activity, ctx) 27 | end 28 | 29 | defp do_create(%Activity.Fail{} = activity, ctx) do 30 | State.Fail.create(activity, ctx) 31 | end 32 | 33 | defp do_create(%Activity.Map{} = activity, ctx) do 34 | State.Map.create(activity, ctx) 35 | end 36 | 37 | defp do_create(%Activity.Pass{} = activity, ctx) do 38 | State.Pass.create(activity, ctx) 39 | end 40 | 41 | defp do_create(%Activity.Parallel{} = activity, ctx) do 42 | State.Parallel.create(activity, ctx) 43 | end 44 | 45 | defp do_create(%Activity.Succeed{} = activity, ctx) do 46 | State.Succeed.create(activity, ctx) 47 | end 48 | 49 | defp do_create(%Activity.Task{} = activity, ctx) do 50 | State.Task.create(activity, ctx) 51 | end 52 | 53 | defp do_create(%Activity.Wait{} = activity, ctx) do 54 | State.Wait.create(activity, ctx) 55 | end 56 | 57 | defp do_execute(%State.Choice{} = state, activity, ctx) do 58 | State.Choice.execute(state, activity, ctx) 59 | end 60 | 61 | defp do_execute(%State.Fail{} = state, activity, ctx) do 62 | State.Fail.execute(state, activity, ctx) 63 | end 64 | 65 | defp do_execute(%State.Map{} = state, activity, ctx) do 66 | State.Map.execute(state, activity, ctx) 67 | end 68 | 69 | defp do_execute(%State.Pass{} = state, activity, ctx) do 70 | State.Pass.execute(state, activity, ctx) 71 | end 72 | 73 | defp do_execute(%State.Parallel{} = state, activity, ctx) do 74 | State.Parallel.execute(state, activity, ctx) 75 | end 76 | 77 | defp do_execute(%State.Succeed{} = state, activity, ctx) do 78 | State.Succeed.execute(state, activity, ctx) 79 | end 80 | 81 | defp do_execute(%State.Task{} = state, activity, ctx) do 82 | State.Task.execute(state, activity, ctx) 83 | end 84 | 85 | defp do_execute(%State.Wait{} = state, activity, ctx) do 86 | State.Wait.execute(state, activity, ctx) 87 | end 88 | 89 | defp do_project(%State.Choice{} = state, activity, event) do 90 | State.Choice.project(state, activity, event) 91 | end 92 | 93 | defp do_project(%State.Fail{} = state, activity, event) do 94 | State.Fail.project(state, activity, event) 95 | end 96 | 97 | defp do_project(%State.Map{} = state, activity, event) do 98 | State.Map.project(state, activity, event) 99 | end 100 | 101 | defp do_project(%State.Pass{} = state, activity, event) do 102 | State.Pass.project(state, activity, event) 103 | end 104 | 105 | defp do_project(%State.Parallel{} = state, activity, event) do 106 | State.Parallel.project(state, activity, event) 107 | end 108 | 109 | defp do_project(%State.Succeed{} = state, activity, event) do 110 | State.Succeed.project(state, activity, event) 111 | end 112 | 113 | defp do_project(%State.Task{} = state, activity, event) do 114 | State.Task.project(state, activity, event) 115 | end 116 | 117 | defp do_project(%State.Wait{} = state, activity, event) do 118 | State.Wait.project(state, activity, event) 119 | end 120 | 121 | defp do_execute_command(%State.Map{} = state, activity, ctx, cmd) do 122 | State.Map.execute(state, activity, ctx, cmd) 123 | end 124 | 125 | defp do_execute_command(%State.Parallel{} = state, activity, ctx, cmd) do 126 | State.Parallel.execute(state, activity, ctx, cmd) 127 | end 128 | 129 | defp do_execute_command(%State.Task{} = state, activity, ctx, cmd) do 130 | State.Task.execute(state, activity, ctx, cmd) 131 | end 132 | 133 | defp do_execute_command(%State.Wait{} = state, activity, ctx, cmd) do 134 | State.Wait.execute(state, activity, ctx, cmd) 135 | end 136 | 137 | defp do_execute_command(_state, _activity, _ctx, cmd) do 138 | {:error, :invalid_command, cmd} 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/workflows/workflow.ex: -------------------------------------------------------------------------------- 1 | defmodule Workflows.Workflow do 2 | @moduledoc false 3 | 4 | alias Workflows.Activity 5 | alias Workflows.Command 6 | alias Workflows.Event 7 | alias Workflows.State 8 | 9 | @type activities :: %{Activity.name() => Activity.t()} 10 | @type t :: %__MODULE__{ 11 | start_at: Activity.name(), 12 | activities: activities() 13 | } 14 | 15 | @type project_result :: {:continue, State.t()} | {:succeed, Activity.args()} | {:error, term()} 16 | 17 | defstruct [:activities, :start_at] 18 | 19 | @spec parse(map()) :: {:ok, t()} | {:error, term()} 20 | def parse(definition) do 21 | do_parse(definition) 22 | end 23 | 24 | @spec create(Activity.name(), activities()) :: t() 25 | def create(start_at, activities) do 26 | %__MODULE__{ 27 | start_at: start_at, 28 | activities: activities 29 | } 30 | end 31 | 32 | @spec starting_activity(t()) :: {:ok, Activity.t()} | {:error, term()} 33 | def starting_activity(workflow) do 34 | activity(workflow, workflow.start_at) 35 | end 36 | 37 | @spec activity(t(), Activity.name()) :: {:ok, Activity.t()} | {:error, term()} 38 | def activity(workflow, name) do 39 | Map.fetch(workflow.activities, name) 40 | end 41 | 42 | @spec execute(t(), State.t(), Activity.ctx()) :: State.execute_result() 43 | def execute(workflow, state, ctx) do 44 | with {:ok, current_activity} <- activity(workflow, state.activity) do 45 | State.execute(state, current_activity, ctx) 46 | end 47 | end 48 | 49 | @spec execute(t(), State.t(), Activity.ctx(), Command.t()) :: State.execute_command_result() 50 | def execute(workflow, state, ctx, cmd) do 51 | with {:ok, current_activity} <- activity(workflow, state.activity) do 52 | State.execute(state, current_activity, ctx, cmd) 53 | end 54 | end 55 | 56 | @spec project(t(), State.t() | nil, list(Event.t()) | Event.t()) :: project_result() 57 | def project(workflow, state, events) do 58 | do_project(workflow, state, events) 59 | end 60 | 61 | ## Private 62 | 63 | defp do_parse(%{"StartAt" => start_at, "States" => states}) do 64 | with {:ok, states} <- parse_states(Map.to_list(states), []) do 65 | {:ok, create(start_at, states)} 66 | end 67 | end 68 | 69 | defp do_parse(_definition), do: {:error, "Definition requires StartAt and States fields"} 70 | 71 | defp parse_states([], acc), do: {:ok, Enum.into(acc, %{})} 72 | 73 | defp parse_states([{state_name, state_def} | states], acc) do 74 | with {:ok, state} <- Activity.parse(state_name, state_def) do 75 | parse_states(states, [{state_name, state} | acc]) 76 | end 77 | end 78 | 79 | defp do_project(_workflow, state, []) do 80 | {:continue, state} 81 | end 82 | 83 | defp do_project(workflow, state, [event | events]) do 84 | case do_project(workflow, state, event) do 85 | {:continue, new_state} -> do_project(workflow, new_state, events) 86 | {:succeed, result} -> {:succeed, result} 87 | end 88 | end 89 | 90 | defp do_project(workflow, state, event) do 91 | with {:ok, current_activity} <- activity(workflow, state.activity) do 92 | case State.project(state, current_activity, event) do 93 | {:stay, new_state} -> 94 | {:continue, new_state} 95 | 96 | {:transition, {:next, activity_name}, args} -> 97 | with {:ok, new_activity} <- activity(workflow, activity_name) do 98 | new_state = State.create(new_activity, args) 99 | {:continue, new_state} 100 | end 101 | 102 | {:transition, :end, result} -> 103 | {:succeed, result} 104 | 105 | {:succeed, result} -> 106 | {:succeed, result} 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/supabase/workflows" 5 | @version "0.2.0" 6 | 7 | def project do 8 | [ 9 | name: "Workflows", 10 | app: :workflows, 11 | version: @version, 12 | elixir: "~> 1.9", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | dialyzer: dialyzer(), 16 | description: description(), 17 | package: package(), 18 | docs: docs() 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | # Run "mix help deps" to learn about dependencies. 29 | defp deps do 30 | [ 31 | {:warpath, "~> 0.6.0"}, 32 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 33 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 34 | {:ex_doc, ">= 0.0.0", only: [:dev, :test], runtime: false} 35 | ] 36 | end 37 | 38 | defp dialyzer do 39 | [ 40 | plt_core_path: "priv/plts", 41 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 42 | ] 43 | end 44 | 45 | defp description() do 46 | """ 47 | Amazon States Language workflow interpreter. 48 | """ 49 | end 50 | 51 | defp package() do 52 | [ 53 | maintainers: ["The Supabase Team"], 54 | licenses: ["Apache-2.0"], 55 | links: %{"GitHub" => @source_url} 56 | ] 57 | end 58 | 59 | defp docs() do 60 | [ 61 | main: "Workflows", 62 | source_ref: "v#{@version}", 63 | canonical: "http://hexdocs.pm/workflows", 64 | source_url: @source_url, 65 | extras: ["CHANGELOG.md", "LICENSE"] 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, 4 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.24.1", "15673de99154f93ca7f05900e4e4155ced1ee0cd34e0caeee567900a616871a4", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "07972f17bdf7dc7b5bd76ec97b556b26178ed3f056e7ec9288eb7cea7f91cce2"}, 8 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 9 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 10 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 14 | "warpath": {:hex, :warpath, "0.6.0", "cf0d7131ef7c81a17bbc2b3d2f1791e70201a76b90603d8e165decc5db945182", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4eafd8a18fdc4e3cc8f56e46753da9ce891cc79f7f7938fe77528db4d684c3fa"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/state/choice_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.ChoiceTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State} 5 | 6 | @ctx %{ 7 | "state" => "choice" 8 | } 9 | 10 | @activity %{ 11 | "Type" => "Choice", 12 | "Default" => "DefaultState", 13 | "Choices" => [ 14 | %{ 15 | "Next" => "Public", 16 | "Not" => %{ 17 | "Variable" => "$.type", 18 | "StringEquals" => "Private" 19 | } 20 | }, 21 | %{ 22 | "Next" => "ValueInTwenties", 23 | "And" => [ 24 | %{ 25 | "Variable" => "$.value", 26 | "IsPresent" => true 27 | }, 28 | %{ 29 | "Variable" => "$.value", 30 | "IsNumeric" => true 31 | }, 32 | %{ 33 | "Variable" => "$.value", 34 | "NumericGreaterThanEquals" => 20 35 | }, 36 | %{ 37 | "Variable" => "$.value", 38 | "NumericLessThan" => 30 39 | } 40 | ] 41 | } 42 | ] 43 | } 44 | 45 | test "completes without external commands" do 46 | {:ok, activity} = Activity.parse("Test", @activity) 47 | 48 | state = State.Choice.create(activity, %{"type" => "Private", "value" => 25}) 49 | 50 | {:ok, started} = State.execute(state, activity, @ctx) 51 | {:stay, new_state} = State.project(state, activity, started) 52 | {:ok, ended} = State.execute(new_state, activity, @ctx) 53 | 54 | {:transition, {:next, "ValueInTwenties"}, _result} = State.project(new_state, activity, ended) 55 | end 56 | 57 | test "completes without external commands and moves to default" do 58 | {:ok, activity} = Activity.parse("Test", @activity) 59 | 60 | state = State.Choice.create(activity, %{"type" => "Private", "value" => 35}) 61 | 62 | {:ok, started} = State.execute(state, activity, @ctx) 63 | {:stay, new_state} = State.project(state, activity, started) 64 | {:ok, ended} = State.execute(new_state, activity, @ctx) 65 | {:transition, {:next, "DefaultState"}, _result} = State.project(new_state, activity, ended) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/state/fail_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.FailTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State} 5 | 6 | @ctx %{ 7 | "state" => "fail" 8 | } 9 | 10 | @activity %{ 11 | "Type" => "Fail", 12 | "Error" => "CustomError", 13 | "Cause" => "Failing" 14 | } 15 | 16 | test "completes and fails without external commands" do 17 | {:ok, activity} = Activity.parse("Test", @activity) 18 | 19 | state = State.Fail.create(activity, %{}) 20 | 21 | {:ok, started} = State.execute(state, activity, @ctx) 22 | {:stay, new_state} = State.project(state, activity, started) 23 | {:ok, ended} = State.execute(new_state, activity, @ctx) 24 | {:fail, error} = State.project(new_state, activity, ended) 25 | 26 | assert "CustomError" == error.name 27 | assert "Failing" == error.cause 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/state/map_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.MapTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State, Command} 5 | 6 | @ctx %{ 7 | "state" => "map" 8 | } 9 | 10 | test "runs all items in parallel by default" do 11 | activity = %{ 12 | "Type" => "Map", 13 | "Next" => "NextState", 14 | "Iterator" => %{ 15 | "StartAt" => "A1", 16 | "States" => %{ 17 | "A1" => %{ 18 | "Type" => "Task", 19 | "Resource" => "supabase:send-email", 20 | "End" => true 21 | } 22 | } 23 | } 24 | } 25 | 26 | {:ok, activity} = Activity.parse("Test", activity) 27 | 28 | state = State.Map.create(activity, [10, 20]) 29 | 30 | {:ok, entered} = State.execute(state, activity, @ctx) 31 | 32 | {:stay, new_state} = State.project(state, activity, entered) 33 | {:ok, started} = State.execute(new_state, activity, @ctx) 34 | 35 | {:stay, new_state} = State.project(new_state, activity, started) 36 | {:ok, a1_0_entered} = State.execute(new_state, activity, @ctx) 37 | 38 | {:stay, new_state} = State.project(new_state, activity, a1_0_entered) 39 | {:ok, a1_0_started} = State.execute(new_state, activity, @ctx) 40 | 41 | {:stay, new_state} = State.project(new_state, activity, a1_0_started) 42 | {:ok, a1_1_entered} = State.execute(new_state, activity, @ctx) 43 | 44 | {:stay, new_state} = State.project(new_state, activity, a1_1_entered) 45 | {:ok, a1_1_started} = State.execute(new_state, activity, @ctx) 46 | 47 | {:stay, new_state} = State.project(new_state, activity, a1_1_started) 48 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 49 | 50 | # Now 2 tasks are "running" concurrently. 51 | 52 | complete_a1_0 = Command.complete_task(a1_0_started, %{"user_id" => a1_0_started.args}) 53 | {:ok, a1_0_succeed} = State.execute(new_state, activity, @ctx, complete_a1_0) 54 | complete_a1_1 = Command.complete_task(a1_1_started, %{"user_id" => a1_1_started.args}) 55 | {:ok, a1_1_succeed} = State.execute(new_state, activity, @ctx, complete_a1_1) 56 | 57 | {:stay, new_state} = State.project(new_state, activity, a1_1_succeed) 58 | {:stay, new_state} = State.project(new_state, activity, a1_0_succeed) 59 | {:ok, a1_0_exited} = State.execute(new_state, activity, @ctx) 60 | 61 | {:stay, new_state} = State.project(new_state, activity, a1_0_exited) 62 | {:ok, a1_1_exited} = State.execute(new_state, activity, @ctx) 63 | 64 | {:stay, new_state} = State.project(new_state, activity, a1_1_exited) 65 | {:ok, succeed} = State.execute(new_state, activity, @ctx) 66 | 67 | {:stay, new_state} = State.project(new_state, activity, succeed) 68 | {:ok, exited} = State.execute(new_state, activity, @ctx) 69 | 70 | {:transition, {:next, "NextState"}, result} = State.project(new_state, activity, exited) 71 | 72 | assert length(result) == 2 73 | end 74 | 75 | test "runs a maximum number of items in parallel if MaxConcurrency is set" do 76 | activity = %{ 77 | "Type" => "Map", 78 | "Next" => "NextState", 79 | "MaxConcurrency" => 1, 80 | "Iterator" => %{ 81 | "StartAt" => "A1", 82 | "States" => %{ 83 | "A1" => %{ 84 | "Type" => "Task", 85 | "Resource" => "supabase:send-email", 86 | "End" => true 87 | } 88 | } 89 | } 90 | } 91 | 92 | {:ok, activity} = Activity.parse("Test", activity) 93 | 94 | state = State.Map.create(activity, [10, 20]) 95 | 96 | {:ok, entered} = State.execute(state, activity, @ctx) 97 | 98 | {:stay, new_state} = State.project(state, activity, entered) 99 | {:ok, started} = State.execute(new_state, activity, @ctx) 100 | 101 | {:stay, new_state} = State.project(new_state, activity, started) 102 | {:ok, a1_0_entered} = State.execute(new_state, activity, @ctx) 103 | 104 | {:stay, new_state} = State.project(new_state, activity, a1_0_entered) 105 | {:ok, a1_0_started} = State.execute(new_state, activity, @ctx) 106 | 107 | {:stay, new_state} = State.project(new_state, activity, a1_0_started) 108 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 109 | 110 | # Wait for task A1 0 to finish before starting next item 111 | complete_a1_0 = Command.complete_task(a1_0_started, %{"user_id" => a1_0_started.args}) 112 | {:ok, a1_0_succeed} = State.execute(new_state, activity, @ctx, complete_a1_0) 113 | 114 | {:stay, new_state} = State.project(new_state, activity, a1_0_succeed) 115 | {:ok, a1_0_exited} = State.execute(new_state, activity, @ctx) 116 | 117 | {:stay, new_state} = State.project(new_state, activity, a1_0_exited) 118 | {:ok, a1_1_entered} = State.execute(new_state, activity, @ctx) 119 | 120 | {:stay, new_state} = State.project(new_state, activity, a1_1_entered) 121 | {:ok, a1_1_started} = State.execute(new_state, activity, @ctx) 122 | 123 | {:stay, new_state} = State.project(new_state, activity, a1_1_started) 124 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 125 | 126 | complete_a1_1 = Command.complete_task(a1_1_started, %{"user_id" => a1_1_started.args}) 127 | {:ok, a1_1_succeed} = State.execute(new_state, activity, @ctx, complete_a1_1) 128 | 129 | {:stay, new_state} = State.project(new_state, activity, a1_1_succeed) 130 | {:ok, a1_1_exited} = State.execute(new_state, activity, @ctx) 131 | 132 | {:stay, new_state} = State.project(new_state, activity, a1_1_exited) 133 | {:ok, succeed} = State.execute(new_state, activity, @ctx) 134 | 135 | {:stay, new_state} = State.project(new_state, activity, succeed) 136 | {:ok, exited} = State.execute(new_state, activity, @ctx) 137 | 138 | {:transition, {:next, "NextState"}, result} = State.project(new_state, activity, exited) 139 | 140 | assert length(result) == 2 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/state/parallel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.ParallelTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State, Command} 5 | 6 | @ctx %{ 7 | "state" => "parallel" 8 | } 9 | 10 | @state_args %{ 11 | "channel_id" => "channel-123" 12 | } 13 | 14 | @activity %{ 15 | "Type" => "Parallel", 16 | "Next" => "NextState", 17 | "Branches" => [ 18 | %{ 19 | "StartAt" => "B1", 20 | "States" => %{ 21 | "B1" => %{ 22 | "Type" => "Task", 23 | "Resource" => "supabase:slack-notification", 24 | "Next" => "B2" 25 | }, 26 | "B2" => %{ 27 | "Type" => "Pass", 28 | "End" => true 29 | } 30 | } 31 | }, 32 | %{ 33 | "StartAt" => "A1", 34 | "States" => %{ 35 | "A1" => %{ 36 | "Type" => "Pass", 37 | "Next" => "A2" 38 | }, 39 | "A2" => %{ 40 | "Type" => "Succeed" 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | 47 | test "completes when children complete" do 48 | {:ok, activity} = Activity.parse("Test", @activity) 49 | 50 | state = State.Parallel.create(activity, @state_args) 51 | 52 | {:ok, entered} = State.execute(state, activity, @ctx) 53 | {:stay, new_state} = State.project(state, activity, entered) 54 | {:ok, started} = State.execute(new_state, activity, @ctx) 55 | 56 | {:stay, new_state} = State.project(new_state, activity, started) 57 | {:ok, b1_entered} = State.execute(new_state, activity, @ctx) 58 | 59 | {:stay, new_state} = State.project(new_state, activity, b1_entered) 60 | {:ok, b1_started} = State.execute(new_state, activity, @ctx) 61 | 62 | {:stay, new_state} = State.project(new_state, activity, b1_started) 63 | {:ok, a1_entered} = State.execute(new_state, activity, @ctx) 64 | 65 | {:stay, new_state} = State.project(new_state, activity, a1_entered) 66 | {:ok, a1_exited} = State.execute(new_state, activity, @ctx) 67 | 68 | {:stay, new_state} = State.project(new_state, activity, a1_exited) 69 | {:ok, a2_entered} = State.execute(new_state, activity, @ctx) 70 | 71 | {:stay, new_state} = State.project(new_state, activity, a2_entered) 72 | {:ok, a2_exited} = State.execute(new_state, activity, @ctx) 73 | 74 | {:stay, new_state} = State.project(new_state, activity, a2_exited) 75 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 76 | 77 | # Now we continue Task b1 78 | 79 | finish_waiting_b1 = %Command.CompleteTask{ 80 | activity: b1_started.activity, 81 | scope: b1_started.scope, 82 | result: %{"email_id" => "abcdef"} 83 | } 84 | 85 | {:ok, b1_succeeded} = State.execute(new_state, activity, @ctx, finish_waiting_b1) 86 | 87 | {:stay, new_state} = State.project(new_state, activity, b1_succeeded) 88 | {:ok, b1_exited} = State.execute(new_state, activity, @ctx) 89 | 90 | {:stay, new_state} = State.project(new_state, activity, b1_exited) 91 | {:ok, b2_entered} = State.execute(new_state, activity, @ctx) 92 | 93 | {:stay, new_state} = State.project(new_state, activity, b2_entered) 94 | {:ok, b2_exited} = State.execute(new_state, activity, @ctx) 95 | 96 | {:stay, new_state} = State.project(new_state, activity, b2_exited) 97 | {:ok, succeeded} = State.execute(new_state, activity, @ctx) 98 | 99 | {:stay, new_state} = State.project(new_state, activity, succeeded) 100 | {:ok, exited} = State.execute(new_state, activity, @ctx) 101 | 102 | {:transition, {:next, "NextState"}, _result} = State.project(new_state, activity, exited) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/state/pass_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.PassTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State} 5 | 6 | @ctx %{ 7 | "state" => "pass" 8 | } 9 | 10 | @activity %{ 11 | "Type" => "Pass", 12 | "Next" => "NextState" 13 | } 14 | 15 | test "completes without external commands" do 16 | {:ok, activity} = Activity.parse("Test", @activity) 17 | 18 | args = %{"arg1" => 123} 19 | 20 | state = State.Pass.create(activity, args) 21 | 22 | {:ok, started} = State.execute(state, activity, @ctx) 23 | {:stay, new_state} = State.project(state, activity, started) 24 | {:ok, ended} = State.execute(new_state, activity, @ctx) 25 | assert {:transition, {:next, "NextState"}, ^args} = State.project(new_state, activity, ended) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/state/succeed_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.SucceedTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State} 5 | 6 | @ctx %{ 7 | "state" => "succeed" 8 | } 9 | 10 | @activity %{ 11 | "Type" => "Succeed" 12 | } 13 | 14 | test "completes without external commands" do 15 | {:ok, activity} = Activity.parse("Test", @activity) 16 | 17 | args = %{"running" => "yes"} 18 | 19 | state = State.Succeed.create(activity, args) 20 | 21 | {:ok, started} = State.execute(state, activity, @ctx) 22 | {:stay, new_state} = State.project(state, activity, started) 23 | {:ok, ended} = State.execute(new_state, activity, @ctx) 24 | assert {:succeed, ^args} = State.project(new_state, activity, ended) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/state/task_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.TaskTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State, Command, Event, Error} 5 | 6 | @ctx %{ 7 | "state" => "task" 8 | } 9 | 10 | @state_args %{ 11 | "user" => %{ 12 | "name" => "Max", 13 | "surname" => "Power" 14 | } 15 | } 16 | 17 | @activity %{ 18 | "Type" => "Task", 19 | "Resource" => "supabase:send-email", 20 | "Next" => "NextState", 21 | "Retry" => [ 22 | %{ 23 | "ErrorEquals" => ["ErrorA", "ErrorB"], 24 | "IntervalSeconds" => 1, 25 | "BackoffRate" => 2, 26 | "MaxAttempts" => 2 27 | }, 28 | %{ 29 | "ErrorEquals" => ["ErrorC"], 30 | "IntervalSeconds" => 5, 31 | "MaxAttempts" => 1 32 | } 33 | ], 34 | "Catch" => [ 35 | %{ 36 | "ErrorEquals" => ["ErrorB"], 37 | "Next" => "Z" 38 | } 39 | ] 40 | } 41 | 42 | test "completes with CompleteTask command" do 43 | {:ok, activity} = Activity.parse("Test", @activity) 44 | 45 | state = State.Task.create(activity, @state_args) 46 | 47 | {:ok, entered} = State.execute(state, activity, @ctx) 48 | {:stay, new_state} = State.project(state, activity, entered) 49 | {:ok, started} = State.execute(new_state, activity, @ctx) 50 | {:stay, new_state} = State.project(new_state, activity, started) 51 | 52 | assert %Event.TaskStarted{resource: "supabase:send-email"} = started 53 | 54 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 55 | 56 | finish_waiting = Command.complete_task(started, %{"email_id" => "abcdef"}) 57 | 58 | {:ok, finished} = State.execute(new_state, activity, @ctx, finish_waiting) 59 | 60 | assert %Event.TaskSucceeded{} = finished 61 | 62 | {:stay, new_state} = State.project(new_state, activity, finished) 63 | {:ok, ended} = State.execute(new_state, activity, @ctx) 64 | {:transition, {:next, "NextState"}, result} = State.project(new_state, activity, ended) 65 | 66 | assert %{"email_id" => "abcdef"} = result 67 | end 68 | 69 | test "retries task with FailTask command, then catch" do 70 | {:ok, activity} = Activity.parse("Test", @activity) 71 | 72 | state = State.Task.create(activity, @state_args) 73 | 74 | {:ok, entered} = State.execute(state, activity, @ctx) 75 | {:stay, new_state} = State.project(state, activity, entered) 76 | {:ok, started} = State.execute(new_state, activity, @ctx) 77 | {:stay, new_state} = State.project(new_state, activity, started) 78 | 79 | assert %Event.TaskStarted{resource: "supabase:send-email"} = started 80 | 81 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 82 | 83 | error_b = Error.create("ErrorB", "Something went wrong") 84 | fail_task = Command.fail_task(started, error_b) 85 | {:ok, retried} = State.execute(new_state, activity, @ctx, fail_task) 86 | assert %Event.TaskRetried{wait: 1} = retried 87 | {:stay, new_state} = State.project(new_state, activity, retried) 88 | 89 | error_c = Error.create("ErrorC", "Something went wrong") 90 | fail_task = Command.fail_task(started, error_c) 91 | {:ok, retried} = State.execute(new_state, activity, @ctx, fail_task) 92 | assert %Event.TaskRetried{wait: 5.0} = retried 93 | {:stay, new_state} = State.project(new_state, activity, retried) 94 | 95 | fail_task = Command.fail_task(started, error_b) 96 | {:ok, retried} = State.execute(new_state, activity, @ctx, fail_task) 97 | assert %Event.TaskRetried{wait: 3} = retried 98 | {:stay, new_state} = State.project(new_state, activity, retried) 99 | 100 | {:ok, failed} = State.execute(new_state, activity, @ctx, fail_task) 101 | assert %Event.TaskFailed{} = failed 102 | {:transition, {:next, "Z"}, error} = State.project(new_state, activity, failed) 103 | assert %{"Name" => "ErrorB", "Cause" => "Something went wrong"} = error 104 | end 105 | 106 | test "retries task with FailTask command, but doesn't catch" do 107 | {:ok, activity} = Activity.parse("Test", @activity) 108 | 109 | state = State.Task.create(activity, @state_args) 110 | 111 | {:ok, entered} = State.execute(state, activity, @ctx) 112 | {:stay, new_state} = State.project(state, activity, entered) 113 | {:ok, started} = State.execute(new_state, activity, @ctx) 114 | {:stay, new_state} = State.project(new_state, activity, started) 115 | 116 | assert %Event.TaskStarted{resource: "supabase:send-email"} = started 117 | 118 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 119 | 120 | error_b = Error.create("ErrorB", "Something went wrong") 121 | fail_task = Command.fail_task(started, error_b) 122 | {:ok, retried} = State.execute(new_state, activity, @ctx, fail_task) 123 | assert %Event.TaskRetried{} = retried 124 | {:stay, new_state} = State.project(new_state, activity, retried) 125 | 126 | error_c = Error.create("ErrorC", "Something went wrong") 127 | fail_task = Command.fail_task(started, error_c) 128 | {:ok, retried} = State.execute(new_state, activity, @ctx, fail_task) 129 | assert %Event.TaskRetried{} = retried 130 | {:stay, new_state} = State.project(new_state, activity, retried) 131 | 132 | fail_task = Command.fail_task(started, error_c) 133 | {:ok, failed} = State.execute(new_state, activity, @ctx, fail_task) 134 | assert %Event.TaskFailed{} = failed 135 | {:fail, error} = State.project(new_state, activity, failed) 136 | 137 | assert "ErrorC" == error.name 138 | assert "Something went wrong" == error.cause 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/state/wait_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Workflows.State.WaitTest do 2 | use ExUnit.Case 3 | 4 | alias Workflows.{Activity, State, Command, Event} 5 | 6 | @ctx %{ 7 | "state" => "wait" 8 | } 9 | 10 | @activity %{ 11 | "Type" => "Wait", 12 | "Seconds" => 10, 13 | "Next" => "NextState" 14 | } 15 | 16 | test "completes with the FinishWaiting command" do 17 | {:ok, activity} = Activity.parse("Test", @activity) 18 | 19 | args = %{"values" => [1, 2, 3]} 20 | 21 | state = State.Wait.create(activity, args) 22 | 23 | {:ok, entered} = State.execute(state, activity, @ctx) 24 | {:stay, new_state} = State.project(state, activity, entered) 25 | {:ok, started} = State.execute(new_state, activity, @ctx) 26 | {:stay, new_state} = State.project(new_state, activity, started) 27 | 28 | assert %Event.WaitStarted{wait: {:seconds, 10}} = started 29 | 30 | {:ok, :no_event} = State.execute(new_state, activity, @ctx) 31 | 32 | finish_waiting = %Command.FinishWaiting{ 33 | activity: started.activity, 34 | scope: started.scope 35 | } 36 | 37 | {:ok, finished} = State.execute(new_state, activity, @ctx, finish_waiting) 38 | 39 | assert %Event.WaitSucceeded{} = finished 40 | 41 | {:stay, new_state} = State.project(new_state, activity, finished) 42 | {:ok, ended} = State.execute(new_state, activity, @ctx) 43 | assert {:transition, {:next, "NextState"}, ^args} = State.project(new_state, activity, ended) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/workflows_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WorkflowsTest do 2 | use ExUnit.Case 3 | doctest Workflows 4 | 5 | alias Workflows.Command 6 | alias Workflows.Execution 7 | alias Workflows.Event 8 | alias Workflows.Workflow 9 | 10 | @ctx %{ 11 | "environment" => "development" 12 | } 13 | 14 | @simple_workflow %{ 15 | "StartAt" => "S1", 16 | "States" => %{ 17 | "S1" => %{ 18 | "Type" => "Pass", 19 | "Next" => "S2" 20 | }, 21 | "S2" => %{ 22 | "Type" => "Succeed" 23 | } 24 | } 25 | } 26 | 27 | @simple_wait_workflow %{ 28 | "StartAt" => "S1", 29 | "States" => %{ 30 | "S1" => %{ 31 | "Type" => "Wait", 32 | "Seconds" => 10, 33 | "Next" => "S2" 34 | }, 35 | "S2" => %{ 36 | "Type" => "Succeed" 37 | } 38 | } 39 | } 40 | 41 | @simple_parallel_workflow %{ 42 | "StartAt" => "S1", 43 | "States" => %{ 44 | "S1" => %{ 45 | "Type" => "Parallel", 46 | "Branches" => [ 47 | %{ 48 | "StartAt" => "A1", 49 | "States" => %{ 50 | "A1" => %{ 51 | "Type" => "Pass", 52 | "End" => true 53 | } 54 | } 55 | }, 56 | %{ 57 | "StartAt" => "B1", 58 | "States" => %{ 59 | "B1" => %{ 60 | "Type" => "Succeed" 61 | } 62 | } 63 | } 64 | ], 65 | "Next" => "S2" 66 | }, 67 | "S2" => %{ 68 | "Type" => "Succeed" 69 | } 70 | } 71 | } 72 | 73 | @parallel_workflow_with_wait %{ 74 | "StartAt" => "S1", 75 | "States" => %{ 76 | "S1" => %{ 77 | "Type" => "Parallel", 78 | "Branches" => [ 79 | %{ 80 | "StartAt" => "A1", 81 | "States" => %{ 82 | "A1" => %{ 83 | "Type" => "Wait", 84 | "Seconds" => 5, 85 | "End" => true 86 | } 87 | } 88 | }, 89 | %{ 90 | "StartAt" => "B1", 91 | "States" => %{ 92 | "B1" => %{ 93 | "Type" => "Wait", 94 | "Seconds" => 10, 95 | "End" => true 96 | } 97 | } 98 | } 99 | ], 100 | "Next" => "S2" 101 | }, 102 | "S2" => %{ 103 | "Type" => "Succeed" 104 | } 105 | } 106 | } 107 | 108 | @nested_parallel_workflows %{ 109 | "StartAt" => "S1", 110 | "States" => %{ 111 | "S1" => %{ 112 | "Type" => "Parallel", 113 | "Branches" => [ 114 | %{ 115 | "StartAt" => "A1", 116 | "States" => %{ 117 | "A1" => %{ 118 | "Type" => "Wait", 119 | "Seconds" => 5, 120 | "End" => true 121 | } 122 | } 123 | }, 124 | %{ 125 | "StartAt" => "B1", 126 | "States" => %{ 127 | "B1" => %{ 128 | "Type" => "Parallel", 129 | "Next" => "B2", 130 | "Branches" => [ 131 | %{ 132 | "StartAt" => "C1", 133 | "States" => %{ 134 | "C1" => %{ 135 | "Type" => "Wait", 136 | "Seconds" => 10, 137 | "Next" => "C2" 138 | }, 139 | "C2" => %{ 140 | "Type" => "Pass", 141 | "End" => true 142 | } 143 | } 144 | } 145 | ] 146 | }, 147 | "B2" => %{ 148 | "Type" => "Succeed" 149 | } 150 | } 151 | } 152 | ], 153 | "Next" => "S2" 154 | }, 155 | "S2" => %{ 156 | "Type" => "Succeed" 157 | } 158 | } 159 | } 160 | 161 | @parallel_inside_map_workflow %{ 162 | "StartAt" => "SendEmailsToUsers", 163 | "States" => %{ 164 | "SendEmailsToUsers" => %{ 165 | "Type" => "Map", 166 | "End" => true, 167 | "InputPath" => "$.changes", 168 | "Iterator" => %{ 169 | "StartAt" => "CheckInsert", 170 | "States" => %{ 171 | "CheckInsert" => %{ 172 | "Type" => "Choice", 173 | "Default" => "Complete", 174 | "Choices" => [ 175 | %{ 176 | "Variable" => "$.type", 177 | "StringEquals" => "INSERT", 178 | "Next" => "WaitOneDay" 179 | } 180 | ] 181 | }, 182 | "WaitOneDay" => %{ 183 | "Type" => "Wait", 184 | "Next" => "SendEmail", 185 | "Seconds" => 86400 186 | }, 187 | "SendEmail" => %{ 188 | "Type" => "Task", 189 | "Next" => "Complete", 190 | "Resource" => "send-templated-email", 191 | "Parameters" => %{ 192 | "api_key" => "my-api-key", 193 | "template_id" => "welcome-email", 194 | "payload" => %{ 195 | "name.$" => "$.record.name", 196 | "email.$" => "$.record.email" 197 | } 198 | } 199 | }, 200 | "Complete" => %{ 201 | "Type" => "Succeed" 202 | } 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | test "simple workflow" do 210 | {:ok, wf} = Workflow.parse(@simple_workflow) 211 | 212 | {:succeed, result, events} = Execution.start(wf, @ctx, %{"foo" => 42}) 213 | {:succeed, result_replay, []} = Workflows.recover(wf, events) 214 | 215 | assert result == result_replay 216 | end 217 | 218 | test "simple wait workflow" do 219 | {:ok, wf} = Workflow.parse(@simple_wait_workflow) 220 | 221 | {:continue, exec, events} = Execution.start(wf, @ctx, %{"foo" => 42}) 222 | 223 | wait_started = List.last(events) 224 | assert %Event.WaitStarted{} = wait_started 225 | 226 | cmd = Command.finish_waiting(wait_started) 227 | {:succeed, result, new_events} = Execution.resume(exec, cmd) 228 | 229 | {:succeed, result_replay, []} = Workflows.recover(wf, events ++ new_events) 230 | assert result == result_replay 231 | end 232 | 233 | test "recover from events" do 234 | {:ok, wf} = Workflow.parse(@simple_wait_workflow) 235 | 236 | {:continue, exec, events} = Execution.start(wf, @ctx, %{"foo" => 42}) 237 | 238 | wait_started = List.last(events) 239 | assert %Event.WaitStarted{} = wait_started 240 | 241 | cmd = Command.finish_waiting(wait_started) 242 | {:succeed, result, new_events} = Execution.resume(exec, cmd) 243 | 244 | some_events = events ++ Enum.take(new_events, 2) 245 | # Some events are missing, so recovering will continue execution from that point on. 246 | {:succeed, result_replay, events} = Workflows.recover(wf, some_events) 247 | assert result == result_replay 248 | assert length(events) == 2 249 | end 250 | 251 | test "simple parallel workflow" do 252 | {:ok, wf} = Workflow.parse(@simple_parallel_workflow) 253 | 254 | {:succeed, result, _events} = Execution.start(wf, @ctx, %{"foo" => 42}) 255 | 256 | assert length(result) == 2 257 | end 258 | 259 | test "parallel workflow with wait inside" do 260 | {:ok, wf} = Workflow.parse(@parallel_workflow_with_wait) 261 | 262 | {:continue, exec, events} = Execution.start(wf, @ctx, %{"foo" => 42}) 263 | 264 | wait_b1 = 265 | events 266 | |> Enum.find(fn 267 | %Event.WaitStarted{activity: "B1"} -> true 268 | _ -> false 269 | end) 270 | 271 | wait_a1 = 272 | events 273 | |> Enum.find(fn 274 | %Event.WaitStarted{activity: "A1"} -> true 275 | _ -> false 276 | end) 277 | 278 | finish_b1 = Command.finish_waiting(wait_b1) 279 | {:continue, exec, _events} = Execution.resume(exec, finish_b1) 280 | 281 | finish_a1 = Command.finish_waiting(wait_a1) 282 | {:succeed, result, _events} = Execution.resume(exec, finish_a1) 283 | 284 | assert length(result) == 2 285 | end 286 | 287 | test "nested parallel workflow" do 288 | {:ok, wf} = Workflow.parse(@nested_parallel_workflows) 289 | 290 | {:continue, exec, events} = Execution.start(wf, @ctx, %{"foo" => 42}) 291 | 292 | wait_c1 = 293 | events 294 | |> Enum.find(fn 295 | %Event.WaitStarted{activity: "C1"} -> true 296 | _ -> false 297 | end) 298 | 299 | wait_a1 = 300 | events 301 | |> Enum.find(fn 302 | %Event.WaitStarted{activity: "A1"} -> true 303 | _ -> false 304 | end) 305 | 306 | finish_a1 = Command.finish_waiting(wait_a1) 307 | {:continue, exec, _events} = Execution.resume(exec, finish_a1) 308 | 309 | finish_c1 = Command.finish_waiting(wait_c1) 310 | {:succeed, result, _events} = Execution.resume(exec, finish_c1) 311 | 312 | assert [[_], _] = result 313 | end 314 | 315 | test "parallel inside map workflow" do 316 | db_changes = %{ 317 | "changes" => [ 318 | %{ 319 | "columns" => [ 320 | %{ 321 | "flags" => ["key"], 322 | "name" => "id", 323 | "type" => "int8", 324 | "type_modifier" => 4_294_967_295 325 | }, 326 | %{ 327 | "flags" => [], 328 | "name" => "name", 329 | "type" => "text", 330 | "type_modifier" => 4_294_967_295 331 | }, 332 | %{ 333 | "flags" => [], 334 | "name" => "email", 335 | "type" => "text", 336 | "type_modifier" => 4_294_967_295 337 | } 338 | ], 339 | "commit_timestamp" => "2021-03-17T14:00:26Z", 340 | "record" => %{ 341 | "id" => "101492", 342 | "name" => "Alfred", 343 | "email" => "alfred@example.org" 344 | }, 345 | "schema" => "public", 346 | "table" => "users", 347 | "type" => "INSERT" 348 | } 349 | ], 350 | "commit_timestamp" => "2021-03-17T14:00:26Z" 351 | } 352 | 353 | {:ok, wf} = Workflows.parse(@parallel_inside_map_workflow) 354 | 355 | {:continue, exec, events} = Workflows.start(wf, @ctx, db_changes) 356 | 357 | # Look for event to wait for one day 358 | wait_one_day = 359 | events 360 | |> Enum.find(fn 361 | %Event.WaitStarted{activity: "WaitOneDay"} -> true 362 | _ -> false 363 | end) 364 | 365 | # Don't wait for one day :) 366 | finish_waiting_command = Command.finish_waiting(wait_one_day) 367 | {:continue, exec, events} = Workflows.resume(exec, finish_waiting_command) 368 | 369 | task_started = 370 | events 371 | |> Enum.find(fn 372 | %Event.TaskStarted{activity: "SendEmail"} -> true 373 | _ -> false 374 | end) 375 | 376 | # Check that the arguments to the task are correct 377 | assert %{"email" => "alfred@example.org", "name" => "Alfred"} = task_started.args["payload"] 378 | 379 | # Let's say the API responded with a message-id for the sent email 380 | complete_task_command = Command.complete_task(task_started, %{"message_id" => 123}) 381 | {:succeed, result, _events} = Workflows.resume(exec, complete_task_command) 382 | # Result is inside a list because we are mapping over all inserted users 383 | assert [%{"message_id" => 123}] = result 384 | end 385 | end 386 | --------------------------------------------------------------------------------