orkestr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +832 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/orkestr/orkestr-editor.js +72 -0
  6. data/app/assets/stylesheets/orkestr/application.css +15 -0
  7. data/app/assets/stylesheets/orkestr/theme.css +62 -0
  8. data/app/controllers/orkestr/api/base_controller.rb +45 -0
  9. data/app/controllers/orkestr/api/executions_controller.rb +50 -0
  10. data/app/controllers/orkestr/api/human_tasks_controller.rb +48 -0
  11. data/app/controllers/orkestr/api/registry_controller.rb +40 -0
  12. data/app/controllers/orkestr/api/workflows_controller.rb +53 -0
  13. data/app/controllers/orkestr/application_controller.rb +4 -0
  14. data/app/controllers/orkestr/human_tasks_controller.rb +30 -0
  15. data/app/controllers/orkestr/ui_controller.rb +8 -0
  16. data/app/controllers/orkestr/webhooks_controller.rb +35 -0
  17. data/app/helpers/orkestr/application_helper.rb +4 -0
  18. data/app/helpers/orkestr/ui_helper.rb +34 -0
  19. data/app/javascript/orkestr-ui/index.html +19 -0
  20. data/app/javascript/orkestr-ui/package-lock.json +2050 -0
  21. data/app/javascript/orkestr-ui/package.json +23 -0
  22. data/app/javascript/orkestr-ui/src/OrkestrApp.tsx +152 -0
  23. data/app/javascript/orkestr-ui/src/api/client.ts +59 -0
  24. data/app/javascript/orkestr-ui/src/api/executions.ts +23 -0
  25. data/app/javascript/orkestr-ui/src/api/humanTasks.ts +32 -0
  26. data/app/javascript/orkestr-ui/src/api/index.ts +5 -0
  27. data/app/javascript/orkestr-ui/src/api/registry.ts +10 -0
  28. data/app/javascript/orkestr-ui/src/api/workflows.ts +33 -0
  29. data/app/javascript/orkestr-ui/src/components/Editor/ActionsBuilder.tsx +213 -0
  30. data/app/javascript/orkestr-ui/src/components/Editor/CustomNode.tsx +31 -0
  31. data/app/javascript/orkestr-ui/src/components/Editor/EditorToolbar.tsx +153 -0
  32. data/app/javascript/orkestr-ui/src/components/Editor/FormSchemaBuilder.tsx +390 -0
  33. data/app/javascript/orkestr-ui/src/components/Editor/NodeConfigPanel.tsx +274 -0
  34. data/app/javascript/orkestr-ui/src/components/Editor/NodePalette.tsx +43 -0
  35. data/app/javascript/orkestr-ui/src/components/Editor/RunDialog.tsx +52 -0
  36. data/app/javascript/orkestr-ui/src/components/Editor/WorkflowEditor.tsx +299 -0
  37. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionDetail.tsx +155 -0
  38. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionList.tsx +74 -0
  39. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskForm.tsx +216 -0
  40. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskFormEmbed.tsx +117 -0
  41. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskList.tsx +110 -0
  42. data/app/javascript/orkestr-ui/src/components/Workflows/NewWorkflowDialog.tsx +64 -0
  43. data/app/javascript/orkestr-ui/src/components/Workflows/WorkflowList.tsx +94 -0
  44. data/app/javascript/orkestr-ui/src/components/shared/EntryConditionsEditor.tsx +138 -0
  45. data/app/javascript/orkestr-ui/src/components/shared/ExpressionEditor.tsx +206 -0
  46. data/app/javascript/orkestr-ui/src/components/shared/HumanTaskFormRenderer.tsx +321 -0
  47. data/app/javascript/orkestr-ui/src/components/shared/JsonSchemaForm.tsx +376 -0
  48. data/app/javascript/orkestr-ui/src/components/shared/Loading.tsx +5 -0
  49. data/app/javascript/orkestr-ui/src/components/shared/StatusBadge.tsx +9 -0
  50. data/app/javascript/orkestr-ui/src/fieldRegistry.ts +74 -0
  51. data/app/javascript/orkestr-ui/src/hooks/useApi.ts +30 -0
  52. data/app/javascript/orkestr-ui/src/hooks/useRegistry.ts +35 -0
  53. data/app/javascript/orkestr-ui/src/main.tsx +75 -0
  54. data/app/javascript/orkestr-ui/src/styles/editor.css +445 -0
  55. data/app/javascript/orkestr-ui/src/styles/index.css +478 -0
  56. data/app/javascript/orkestr-ui/src/types/execution.ts +37 -0
  57. data/app/javascript/orkestr-ui/src/types/humanTask.ts +30 -0
  58. data/app/javascript/orkestr-ui/src/types/index.ts +4 -0
  59. data/app/javascript/orkestr-ui/src/types/registry.ts +22 -0
  60. data/app/javascript/orkestr-ui/src/types/workflow.ts +64 -0
  61. data/app/javascript/orkestr-ui/src/vite-env.d.ts +6 -0
  62. data/app/javascript/orkestr-ui/tsconfig.json +21 -0
  63. data/app/javascript/orkestr-ui/tsconfig.tsbuildinfo +1 -0
  64. data/app/javascript/orkestr-ui/vite.config.ts +30 -0
  65. data/app/jobs/orkestr/application_job.rb +4 -0
  66. data/app/jobs/orkestr/execute_workflow_job.rb +10 -0
  67. data/app/jobs/orkestr/resume_execution_job.rb +15 -0
  68. data/app/mailers/orkestr/application_mailer.rb +6 -0
  69. data/app/models/concerns/orkestr/assignable.rb +28 -0
  70. data/app/models/concerns/orkestr/contextualizable.rb +9 -0
  71. data/app/models/orkestr/application_record.rb +6 -0
  72. data/app/models/orkestr/assignee.rb +40 -0
  73. data/app/models/orkestr/context.rb +42 -0
  74. data/app/models/orkestr/edge.rb +58 -0
  75. data/app/models/orkestr/execution.rb +45 -0
  76. data/app/models/orkestr/execution_log.rb +38 -0
  77. data/app/models/orkestr/human_task.rb +63 -0
  78. data/app/models/orkestr/node.rb +48 -0
  79. data/app/models/orkestr/node_execution.rb +59 -0
  80. data/app/models/orkestr/workflow.rb +39 -0
  81. data/app/orkestr_nodes/action/node.rb +77 -0
  82. data/app/orkestr_nodes/condition/node.rb +67 -0
  83. data/app/orkestr_nodes/http_request/node.rb +88 -0
  84. data/app/orkestr_nodes/human_action/node.rb +103 -0
  85. data/app/orkestr_nodes/transform/node.rb +48 -0
  86. data/app/orkestr_nodes/wait/node.rb +18 -0
  87. data/app/orkestr_triggers/manual/trigger.rb +12 -0
  88. data/app/orkestr_triggers/scheduled/trigger.rb +26 -0
  89. data/app/orkestr_triggers/webhook/trigger.rb +23 -0
  90. data/app/serializers/orkestr/assignee_serializer.rb +30 -0
  91. data/app/serializers/orkestr/context_serializer.rb +30 -0
  92. data/app/serializers/orkestr/edge_serializer.rb +33 -0
  93. data/app/serializers/orkestr/execution_collection_serializer.rb +7 -0
  94. data/app/serializers/orkestr/execution_log_serializer.rb +31 -0
  95. data/app/serializers/orkestr/execution_serializer.rb +37 -0
  96. data/app/serializers/orkestr/human_task_serializer.rb +46 -0
  97. data/app/serializers/orkestr/node_execution_serializer.rb +43 -0
  98. data/app/serializers/orkestr/node_serializer.rb +29 -0
  99. data/app/serializers/orkestr/workflow_collection_serializer.rb +11 -0
  100. data/app/serializers/orkestr/workflow_serializer.rb +30 -0
  101. data/app/services/orkestr/entry_condition_evaluator.rb +68 -0
  102. data/app/services/orkestr/execution_service/complete.rb +64 -0
  103. data/app/services/orkestr/execution_service/join_resolver.rb +56 -0
  104. data/app/services/orkestr/execution_service/node_runner.rb +56 -0
  105. data/app/services/orkestr/execution_service/runner.rb +162 -0
  106. data/app/services/orkestr/execution_service/start.rb +90 -0
  107. data/app/services/orkestr/expression_resolver.rb +72 -0
  108. data/app/services/orkestr/human_task_service/complete.rb +62 -0
  109. data/app/services/orkestr/workflow_service/duplicate.rb +30 -0
  110. data/app/services/orkestr/workflow_service/export.rb +26 -0
  111. data/app/services/orkestr/workflow_service/import.rb +29 -0
  112. data/app/services/orkestr/workflow_synchronizer.rb +102 -0
  113. data/app/views/layouts/orkestr/application.html.erb +17 -0
  114. data/app/views/layouts/orkestr/ui.html.erb +18 -0
  115. data/app/views/orkestr/human_tasks/show.html.erb +17 -0
  116. data/app/views/orkestr/ui/index.html.erb +8 -0
  117. data/config/routes.rb +27 -0
  118. data/db/migrate/20260308204133_enable_pgcrypto_extension.rb +5 -0
  119. data/db/migrate/20260308204558_create_orkestr_workflows.rb +12 -0
  120. data/db/migrate/20260308204703_create_orkestr_nodes.rb +12 -0
  121. data/db/migrate/20260308204807_create_orkestr_edges.rb +12 -0
  122. data/db/migrate/20260308204931_create_orkestr_executions.rb +13 -0
  123. data/db/migrate/20260308205023_create_orkestr_node_executions.rb +16 -0
  124. data/db/migrate/20260308205119_add_react_flow_id_to_nodes.rb +6 -0
  125. data/db/migrate/20260308205123_add_react_flow_id_to_edges.rb +6 -0
  126. data/db/migrate/20260308205745_add_workflow_to_executions.rb +5 -0
  127. data/db/migrate/20260308205940_make_executable_nullable_on_executions.rb +6 -0
  128. data/db/migrate/20260308220730_create_orkestr_human_tasks.rb +15 -0
  129. data/db/migrate/20260308220900_create_orkestr_assignees.rb +13 -0
  130. data/db/migrate/20260308234115_add_unique_index_to_node_executions.rb +7 -0
  131. data/db/migrate/20260309075336_create_orkestr_contexts.rb +10 -0
  132. data/db/migrate/20260309075343_replace_executable_with_context_on_executions.rb +11 -0
  133. data/db/migrate/20260309080416_add_status_key_deadline_to_orkestr_assignees.rb +7 -0
  134. data/db/migrate/20260309082815_add_status_and_workflow_to_orkestr_contexts.rb +7 -0
  135. data/db/migrate/20260309082816_add_status_default_context_and_reuse_context_to_orkestr_workflows.rb +7 -0
  136. data/db/migrate/20260309083328_create_orkestr_execution_logs.rb +14 -0
  137. data/db/migrate/20260310223204_replace_node_executions_unique_index_for_cycle_support.rb +18 -0
  138. data/lib/orkestr/configuration.rb +16 -0
  139. data/lib/orkestr/engine.rb +48 -0
  140. data/lib/orkestr/nodes/base.rb +74 -0
  141. data/lib/orkestr/nodes/loader.rb +32 -0
  142. data/lib/orkestr/nodes/registry.rb +34 -0
  143. data/lib/orkestr/nodes/schema_dsl.rb +73 -0
  144. data/lib/orkestr/triggers/base.rb +49 -0
  145. data/lib/orkestr/triggers/loader.rb +29 -0
  146. data/lib/orkestr/triggers/registry.rb +34 -0
  147. data/lib/orkestr/triggers/schema_dsl.rb +45 -0
  148. data/lib/orkestr/version.rb +3 -0
  149. data/lib/orkestr.rb +27 -0
  150. data/lib/tasks/annotate_rb.rake +10 -0
  151. data/lib/tasks/orkestr_tasks.rake +19 -0
  152. metadata +251 -0
data/README.md ADDED
@@ -0,0 +1,832 @@
1
+ # Orkestr
2
+
3
+ Rails Engine for building and executing workflows. Define workflows as directed graphs of nodes, execute them with a pluggable parallel engine, and integrate human tasks, triggers, and custom plugins.
4
+
5
+ <p align="center">
6
+ <img src="docs/screenshots/01-dashboard.png" alt="Workflow Dashboard" width="800" />
7
+ </p>
8
+
9
+ ## Features
10
+
11
+ - **Graph-based workflows** — directed acyclic graph of nodes connected by edges
12
+ - **Parallel execution** — nodes in independent branches run concurrently via `concurrent-ruby`
13
+ - **Conditional branching** — route execution based on conditions with `source_handle` edges
14
+ - **Join/merge** — converge parallel branches with `join: all` or `join: any` semantics
15
+ - **Wait/resume** — pause execution and resume later with external data
16
+ - **Human-in-the-loop** — create tasks with form schemas, assignees, deadlines, and response validation
17
+ - **Contextualizable** — attach workflows to any host app model (polymorphic)
18
+ - **Reusable contexts** — optionally reuse an active context for the same entity/workflow
19
+ - **Execution logging** — structured logs (debug/info/warn/error) from nodes and triggers
20
+ - **Plugin system** — auto-discovered nodes and triggers with JSON Schema DSL
21
+ - **REST API** — full CRUD for workflows, executions, human tasks, and registry
22
+ - **Visual editor** — React Flow web component (`<orkestr-editor>`) with Shadow DOM isolation
23
+ - **Workflow management** — duplicate, export (JSON), and import workflows
24
+ - **Webhook triggers** — HTTP webhook with optional secret verification
25
+ - **Scheduled triggers** — cron-based scheduling via `fugit`
26
+ - **Cycle detection** — DFS-based detection prevents infinite graph loops (configurable via `allow_cycles`)
27
+ - **Async execution** — optional background job execution via configurable job backend
28
+ - **Expression system** — n8n-like `{{ input.x }}`, `{{ context.x }}`, `{{ nodes.<id>.x }}` expressions resolved at runtime in node configs
29
+ - **Action node with Ruby sandbox** — execute Ruby code in node configs with access to `input`, `context`, `output`, and optional context updates
30
+
31
+ ## Installation
32
+
33
+ Add to your Gemfile:
34
+
35
+ ```ruby
36
+ gem "orkestr"
37
+ ```
38
+
39
+ Then run:
40
+
41
+ ```bash
42
+ bundle install
43
+ rake orkestr:install:migrations
44
+ rake db:migrate
45
+ ```
46
+
47
+ Mount the engine in `config/routes.rb`:
48
+
49
+ ```ruby
50
+ Rails.application.routes.draw do
51
+ mount Orkestr::Engine => "/orkestr"
52
+ end
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ Create an initializer `config/initializers/orkestr.rb`:
58
+
59
+ ```ruby
60
+ Orkestr.configure do |config|
61
+ # Additional paths to load custom node plugins
62
+ config.nodes_paths = [Rails.root.join("app/workflow_nodes")]
63
+
64
+ # Additional paths to load custom trigger plugins
65
+ config.triggers_paths = [Rails.root.join("app/workflow_triggers")]
66
+
67
+ # Job backend: :async (default) or :sidekiq
68
+ config.job_backend = :async
69
+
70
+ # Authentication (optional) — receives the request, returns the current user or nil
71
+ config.authenticator = ->(request) {
72
+ token = request.headers["Authorization"]&.sub("Bearer ", "")
73
+ User.find_by(api_token: token) if token.present?
74
+ }
75
+
76
+ # Authorization (optional) — receives the current user and request
77
+ config.authorizer = ->(user, request) {
78
+ user.admin?
79
+ }
80
+ end
81
+ ```
82
+
83
+ ### Options
84
+
85
+ | Option | Default | Description |
86
+ |---|---|---|
87
+ | `nodes_paths` | `[]` | Additional paths to load custom node plugins |
88
+ | `triggers_paths` | `[]` | Additional paths to load custom trigger plugins |
89
+ | `job_backend` | `:async` | Background job backend (`:async` or `:sidekiq`) |
90
+ | `authenticator` | `nil` | Callable for API authentication (open access when nil) |
91
+ | `authorizer` | `nil` | Callable for API authorization |
92
+ | `custom_ui_scripts` | `[]` | JS asset paths to inject into the engine's built-in UI page (for custom field types, etc.) |
93
+ | `custom_ui_styles` | `[]` | CSS asset paths to inject into host app pages (for theming the web component) |
94
+ | `allow_cycles` | `false` | Allow cyclic graphs (loops). The Runner's MAX_ITERATIONS (1000) prevents runaway loops at execution time |
95
+
96
+ ## Web UI
97
+
98
+ Orkestr ships a built-in visual editor powered by React Flow, delivered as a Web Component. Access it at:
99
+
100
+ ```
101
+ http://localhost:3000/orkestr/ui
102
+ ```
103
+
104
+ ### Visual Workflow Editor
105
+
106
+ Design workflows visually with drag-and-drop nodes, conditional branching, and parallel paths.
107
+
108
+ | Document Review | Data Pipeline | Employee Onboarding |
109
+ |:---:|:---:|:---:|
110
+ | ![Document Review](docs/screenshots/02-workflow-editor.png) | ![Data Pipeline](docs/screenshots/08-pipeline-workflow.png) | ![Onboarding](docs/screenshots/07-onboarding-workflow.png) |
111
+ | Conditional branching (approve/reject) | HTTP + Transform + parallel fan-out | Parallel human tasks + join |
112
+
113
+ ### Node Configuration
114
+
115
+ Click any node to configure it. The right panel shows node-specific settings, expression fields (`{x}` buttons), and the form builder for human tasks.
116
+
117
+ <p align="center">
118
+ <img src="docs/screenshots/03-node-config-panel.png" alt="Node Configuration Panel" width="800" />
119
+ </p>
120
+
121
+ ### Human Task Forms
122
+
123
+ Rich form fields including text, textarea, checkbox, select (static + dynamic), multi-select, date, email, URL, and custom field types (slider, rating) registered by the host app.
124
+
125
+ <p align="center">
126
+ <img src="docs/screenshots/05-human-task-form.png" alt="Human Task Form" width="600" />
127
+ </p>
128
+
129
+ ### Embedding in your app
130
+
131
+ The `<orkestr-editor>` Web Component works in any frontend stack:
132
+
133
+ ```erb
134
+ <!-- ERB -->
135
+ <%= orkestr_editor_tag api_base_url: "/orkestr" %>
136
+
137
+ <!-- Or manually -->
138
+ <script src="/assets/orkestr/orkestr-editor.js" defer></script>
139
+ <orkestr-editor api-base-url="/orkestr" mode="dashboard" style="display:block;width:100%;height:100vh;"></orkestr-editor>
140
+ ```
141
+
142
+ ```html
143
+ <!-- Vue / Any HTML -->
144
+ <orkestr-editor api-base-url="/orkestr" auth-token="your-token" mode="dashboard"></orkestr-editor>
145
+ ```
146
+
147
+ ### Attributes
148
+
149
+ | Attribute | Default | Description |
150
+ |---|---|---|
151
+ | `api-base-url` | `/orkestr` | Engine mount path |
152
+ | `auth-token` | - | Bearer token for API authentication |
153
+ | `mode` | `dashboard` | `dashboard` (full UI), `editor` (single workflow), or `task` (task form only) |
154
+ | `workflow-id` | - | Open a specific workflow in editor mode |
155
+ | `task-id` | - | Task to display when `mode="task"` |
156
+ | `prefill` | - | JSON string of pre-filled form values (used with `mode="task"`) |
157
+
158
+ ### Task form mode
159
+
160
+ Use `mode="task"` to embed a human task form without any dashboard chrome. The form is rendered entirely by the web component — no server-side form rendering needed. This keeps Orkestr frontend-agnostic: the host app only provides the container (modal, panel, page…).
161
+
162
+ ```html
163
+ <!-- Embed in any container — modal, panel, sidebar, page -->
164
+ <orkestr-editor
165
+ api-base-url="/orkestr"
166
+ auth-token="session-or-api-token"
167
+ mode="task"
168
+ task-id="<task-uuid>"
169
+ style="display:block;min-height:200px;">
170
+ </orkestr-editor>
171
+
172
+ <script>
173
+ // React to task lifecycle events
174
+ document.addEventListener("orkestr:task-completed", function(e) {
175
+ console.log("Task completed:", e.detail.taskId);
176
+ // Close your modal, reload page, etc.
177
+ });
178
+ </script>
179
+ ```
180
+
181
+ With the Rails helper:
182
+
183
+ ```erb
184
+ <%= orkestr_editor_tag mode: "task", task_id: @task.id, auth_token: session_token %>
185
+ ```
186
+
187
+ The engine also provides a standalone HTML page at `/orkestr/human_tasks/:id` that uses this mode — useful as a fallback or for direct links.
188
+
189
+ ### Custom Events
190
+
191
+ The component dispatches DOM events (with `composed: true` to cross Shadow DOM boundaries):
192
+
193
+ | Event | Detail | Fired when |
194
+ |---|---|---|
195
+ | `orkestr:workflow-saved` | `{ workflowId }` | A workflow is saved in the editor |
196
+ | `orkestr:execution-started` | `{ executionId, workflowId }` | A workflow execution starts |
197
+ | `orkestr:task-completed` | `{ taskId }` | A human task form is submitted |
198
+ | `orkestr:task-cancelled` | `{ taskId }` | A human task form is cancelled |
199
+
200
+ ### Building UI assets
201
+
202
+ The gem ships with pre-built assets. To rebuild during development:
203
+
204
+ ```bash
205
+ rake orkestr:build_ui
206
+ ```
207
+
208
+ ## Usage
209
+
210
+ ### Creating a workflow
211
+
212
+ ```ruby
213
+ workflow = Orkestr::Workflow.create!(
214
+ name: "My Workflow",
215
+ status: "published",
216
+ trigger_type: "manual",
217
+ default_context: { team: "engineering" },
218
+ reuse_context: false,
219
+ graph_json: {
220
+ nodes: [
221
+ { id: "1", type: "transform", data: { type: "transform", mappings: [...] } },
222
+ { id: "2", type: "http_request", data: { type: "http_request", url: "..." } }
223
+ ],
224
+ edges: [
225
+ { id: "e1", source: "1", target: "2" }
226
+ ]
227
+ }
228
+ )
229
+
230
+ # Sync the graph to the database (automatic on API create/update)
231
+ Orkestr::WorkflowSynchronizer.new(workflow).call
232
+ ```
233
+
234
+ ### Running a workflow
235
+
236
+ ```ruby
237
+ # Synchronous execution
238
+ execution = Orkestr::ExecutionService::Start.new(
239
+ workflow,
240
+ context: { key: "value" }
241
+ ).call
242
+
243
+ # With a contextualizable entity
244
+ execution = Orkestr::ExecutionService::Start.new(
245
+ workflow,
246
+ context: { key: "value" },
247
+ contextualizable: document
248
+ ).call
249
+
250
+ # Async execution (via configured job backend)
251
+ execution = Orkestr::ExecutionService::Start.new(
252
+ workflow,
253
+ context: {},
254
+ async: true
255
+ ).call
256
+ ```
257
+
258
+ ### Workflow management
259
+
260
+ ```ruby
261
+ # Duplicate
262
+ copy = Orkestr::WorkflowService::Duplicate.new(workflow, name: "My Copy").call
263
+
264
+ # Export to JSON
265
+ data = Orkestr::WorkflowService::Export.new(workflow).call
266
+ json = Orkestr::WorkflowService::Export.new(workflow).to_json
267
+
268
+ # Import from JSON
269
+ workflow = Orkestr::WorkflowService::Import.new(data).call
270
+ ```
271
+
272
+ ### Contexts
273
+
274
+ Contexts carry data through a workflow execution and can be linked to host app models:
275
+
276
+ ```ruby
277
+ # Reuse context: when enabled, subsequent executions for the same
278
+ # entity + workflow reuse the active context instead of creating a new one
279
+ workflow.update!(reuse_context: true)
280
+
281
+ # Default context: merged into every execution's context
282
+ workflow.update!(default_context: { team: "engineering", priority: "normal" })
283
+
284
+ # Query contexts
285
+ Orkestr::Context.active # pending or processing
286
+ Orkestr::Context.for_entity(document) # contexts for a specific entity
287
+ Orkestr::Context.for_workflow(workflow) # contexts for a specific workflow
288
+ ```
289
+
290
+ ### Human Tasks
291
+
292
+ Workflows can pause for human input using the `human_action` node:
293
+
294
+ ```ruby
295
+ # Query pending tasks
296
+ tasks = Orkestr::HumanTask.pending
297
+
298
+ # Complete a task (validates response against schema, resumes the workflow)
299
+ Orkestr::HumanTaskService::Complete.new(task, response: { approved: true }).call
300
+ ```
301
+
302
+ #### Displaying task forms
303
+
304
+ The recommended way to display human task forms is via the web component in `mode="task"`. This keeps the engine frontend-agnostic — the host app controls the container (modal, panel, page) and reacts to DOM events:
305
+
306
+ ```html
307
+ <orkestr-editor api-base-url="/orkestr" auth-token="..." mode="task" task-id="<uuid>">
308
+ </orkestr-editor>
309
+ ```
310
+
311
+ See [Task form mode](#task-form-mode) for details and examples.
312
+
313
+ #### Task Actions
314
+
315
+ Human action nodes support an `actions` config that defines multiple entry points (buttons) for the same task. This is useful for approval/rejection workflows, multi-choice tasks, or any scenario where different actions require different forms.
316
+
317
+ ```yaml
318
+ # In node config (graph_json or workflow YAML)
319
+ actions:
320
+ - label: "Approve"
321
+ icon: "check-circle"
322
+ color: "#16a34a"
323
+ prefill:
324
+ decision: "approve"
325
+ schema:
326
+ type: object
327
+ properties:
328
+ comment:
329
+ type: string
330
+ format: text
331
+ description: "Optional comment"
332
+ - label: "Reject"
333
+ icon: "x-circle"
334
+ color: "#dc2626"
335
+ prefill:
336
+ decision: "reject"
337
+ schema:
338
+ type: object
339
+ required: ["reason"]
340
+ properties:
341
+ reason:
342
+ type: string
343
+ format: select
344
+ description: "Rejection reason"
345
+ options:
346
+ - { label: "Non-compliant", value: "non_compliant" }
347
+ - { label: "Incomplete", value: "incomplete" }
348
+ comment:
349
+ type: string
350
+ format: text
351
+ description: "Comment"
352
+ ```
353
+
354
+ Each action can define:
355
+
356
+ | Property | Required | Description |
357
+ |---|---|---|
358
+ | `label` | Yes | Button text displayed to the user |
359
+ | `icon` | No | Icon name (e.g. Lucide icon names) |
360
+ | `color` | No | Hex color for the button (e.g. `#16a34a`) |
361
+ | `prefill` | No | Key-value pairs pre-filled in the form and hidden from the user |
362
+ | `schema` | No | Per-action form schema that replaces the node's base `form_schema` |
363
+
364
+ **How it works:**
365
+ - In the **task list**, each action renders as a separate colored button
366
+ - When an action has its own `schema`, only that schema's fields are shown
367
+ - When no `schema` is defined, the base `form_schema` is used with prefilled fields hidden
368
+ - The `prefill` values are always included in the submitted response
369
+ - Actions are fully configurable in the visual workflow editor via the "Task Actions" panel
370
+
371
+ #### Form field types
372
+
373
+ The form schema supports all common field types via `type` + `format`:
374
+
375
+ | Type | Format | Renders as |
376
+ |---|---|---|
377
+ | `string` | _(none)_ | Text input |
378
+ | `string` | `text` | Textarea |
379
+ | `string` | `date` | Date picker |
380
+ | `string` | `email` | Email input |
381
+ | `string` | `url` | URL input |
382
+ | `string` | `file` | File upload (base64) |
383
+ | `string` | `select` | Select dropdown |
384
+ | `number` | _(none)_ | Number input |
385
+ | `boolean` | _(none)_ | Checkbox |
386
+
387
+ #### Select fields
388
+
389
+ Select fields support static options, dynamic options from an API, and multiple selection:
390
+
391
+ ```json
392
+ {
393
+ "type": "string",
394
+ "format": "select",
395
+ "multiple": true,
396
+ "options": [
397
+ { "label": "Option A", "value": "a" },
398
+ { "label": "Option B", "value": "b" }
399
+ ],
400
+ "options_url": "/api/users?format=options"
401
+ }
402
+ ```
403
+
404
+ The `options_url` endpoint should return a JSON array. Accepted formats:
405
+ - `[{ "label": "...", "value": "..." }]`
406
+ - `[{ "name": "...", "id": "..." }]`
407
+ - `["string1", "string2"]`
408
+
409
+ Static and dynamic options are merged.
410
+
411
+ #### Custom field types (host app)
412
+
413
+ Host apps can register custom field renderers that appear in both the workflow editor's form builder (type dropdown + config UI) and in human task forms at runtime.
414
+
415
+ **1. Create a JS file** in your app's assets (e.g. `app/assets/javascripts/orkestr_custom_fields.js`):
416
+
417
+ ```javascript
418
+ document.addEventListener("DOMContentLoaded", function () {
419
+ if (!window.OrkestrEditor) return;
420
+
421
+ OrkestrEditor.registerFieldType("slider", {
422
+ label: "Slider", // displayed in the type dropdown
423
+ // Render the field in human task forms
424
+ render: function (container, ctx) {
425
+ var field = ctx.field;
426
+ var input = document.createElement("input");
427
+ input.type = "range";
428
+ input.min = field.min || 0;
429
+ input.max = field.max || 100;
430
+ input.step = field.step || 1;
431
+ input.value = ctx.value || 50;
432
+ input.addEventListener("input", function () {
433
+ ctx.onChange(Number(input.value));
434
+ });
435
+ container.appendChild(input);
436
+ },
437
+ // Render config UI in the workflow editor's form builder (optional)
438
+ configRender: function (container, ctx) {
439
+ var field = ctx.field;
440
+ ["min", "max", "step"].forEach(function (key) {
441
+ var label = document.createElement("label");
442
+ label.className = "ork-label";
443
+ label.textContent = key.charAt(0).toUpperCase() + key.slice(1);
444
+ var input = document.createElement("input");
445
+ input.className = "ork-input";
446
+ input.type = "number";
447
+ input.value = field[key] || "";
448
+ input.addEventListener("change", function () {
449
+ var patch = {};
450
+ patch[key] = input.value ? Number(input.value) : undefined;
451
+ ctx.onChange(patch);
452
+ });
453
+ var group = document.createElement("div");
454
+ group.className = "ork-form-group";
455
+ group.appendChild(label);
456
+ group.appendChild(input);
457
+ container.appendChild(group);
458
+ });
459
+ },
460
+ });
461
+ });
462
+ ```
463
+
464
+ **2. Configure Orkestr** to inject this script into the engine's built-in UI page (`/orkestr/ui`):
465
+
466
+ ```ruby
467
+ # config/initializers/orkestr.rb
468
+ Orkestr.configure do |config|
469
+ config.custom_ui_scripts = ["orkestr_custom_fields"]
470
+ end
471
+ ```
472
+
473
+ **3. Include in your app layout** (for human task forms rendered in host app pages):
474
+
475
+ ```erb
476
+ <%= javascript_include_tag "orkestr_custom_fields" %>
477
+ ```
478
+
479
+ **API reference:**
480
+
481
+ | Callback | Context | Description |
482
+ |---|---|---|
483
+ | `render(container, ctx)` | `ctx.value` — current value, `ctx.field` — full field schema, `ctx.onChange(value)` — update value | Renders the field in task forms |
484
+ | `configRender(container, ctx)` | `ctx.field` — current field config, `ctx.onChange(patch)` — merge config patch | Renders admin config UI in the form builder (e.g. min/max/step for a slider) |
485
+
486
+ Extra properties set via `configRender` (like `min`, `max`, `step`) are persisted in the schema and passed to `render` as `ctx.field` properties at runtime.
487
+
488
+ ### Assignable models
489
+
490
+ Make your models assignable to receive human tasks:
491
+
492
+ ```ruby
493
+ class User < ApplicationRecord
494
+ include Orkestr::Assignable
495
+ end
496
+
497
+ # Find users with pending tasks
498
+ User.with_pending_tasks
499
+
500
+ # Find users with overdue tasks
501
+ User.with_overdue_tasks
502
+ ```
503
+
504
+ ### Contextualizable models
505
+
506
+ Make your models contextualizable to track workflow contexts:
507
+
508
+ ```ruby
509
+ class Document < ApplicationRecord
510
+ include Orkestr::Contextualizable
511
+ end
512
+
513
+ # Access contexts for a document
514
+ document.orkestr_contexts
515
+ ```
516
+
517
+ ### Entry conditions
518
+
519
+ Triggers can define entry conditions to restrict which entities or contexts are allowed to start a workflow. Conditions are stored in `trigger_config` and evaluated before execution starts.
520
+
521
+ ```ruby
522
+ workflow.update!(trigger_config: {
523
+ entry_conditions: {
524
+ rules: [
525
+ # Only allow Document entities
526
+ { field: "contextualizable_type", operator: "eq", value: "Document" },
527
+ # Only when document status is one of these
528
+ { field: "status", operator: "in", value: %w[pending_review draft] }
529
+ ],
530
+ match: "all" # "all" (AND) or "any" (OR)
531
+ }
532
+ })
533
+ ```
534
+
535
+ The evaluation payload is built from (in order of priority):
536
+ 1. `contextualizable_type` — the entity's class name
537
+ 2. Entity attributes (`entity.attributes`) — all model columns
538
+ 3. Context data — the hash passed at execution time
539
+
540
+ Available operators: `eq`, `neq`, `gt`, `lt`, `contains`, `present`, `blank`, `in`.
541
+
542
+ When conditions are not met, `ExecutionService::Start` raises `Orkestr::EntryConditionNotMetError` (HTTP 403 via API).
543
+
544
+ ### Expression System
545
+
546
+ Node configuration fields support dynamic expressions resolved at runtime, similar to n8n. Expressions use the `{{ path }}` syntax:
547
+
548
+ | Prefix | Source | Example |
549
+ |---|---|---|
550
+ | `input` | Output from the previous node | `{{ input.user_id }}` |
551
+ | `context` | Workflow context data | `{{ context.team }}` |
552
+ | `nodes.<id>` | Output of a specific node (by react_flow_id) | `{{ nodes.fetch_user.email }}` |
553
+
554
+ ```ruby
555
+ # Single expression — preserves the raw type (number, boolean, etc.)
556
+ "{{ input.count }}" # => 42 (integer, not "42")
557
+
558
+ # Mixed content — interpolated as string
559
+ "Hello {{ context.name }}, you have {{ input.count }} items"
560
+ # => "Hello Alice, you have 42 items"
561
+ ```
562
+
563
+ Expressions are resolved by `ExpressionResolver` in `NodeRunner` before passing config to the plugin. The visual editor includes an expression picker (`{x}` button) that lists available variables from predecessor nodes and workflow context.
564
+
565
+ ### Action Node (Ruby Code)
566
+
567
+ The `action` node executes Ruby code in a sandboxed environment. Available variables:
568
+
569
+ | Variable | Description |
570
+ |---|---|
571
+ | `input` | Hash — output from the previous node (frozen) |
572
+ | `context` | Hash — workflow context data (frozen) |
573
+ | `output` | Hash — fill this to produce the node's output |
574
+ | `dig(data, "path")` | Helper to navigate nested hashes with dot notation |
575
+
576
+ ```ruby
577
+ # Example: compute a total and update the workflow context
578
+ # Config: code
579
+ output[:total] = input[:price] * input[:quantity]
580
+ output[:processed_at] = Time.current.iso8601
581
+
582
+ # Config: update_context = true
583
+ # → output is merged into the workflow context for downstream nodes
584
+ ```
585
+
586
+ ```ruby
587
+ # Example: aggregate data from an array
588
+ output[:total_items] = input[:items].sum { |i| i[:qty] }
589
+ output[:names] = input[:items].map { |i| i[:name] }.join(", ")
590
+ ```
591
+
592
+ Safety: code runs with a **10-second timeout** to prevent infinite loops. When `update_context` is enabled, the output hash is merged into the workflow's `Context#data`.
593
+
594
+ ### Execution logging
595
+
596
+ Nodes and triggers can log structured messages during execution:
597
+
598
+ ```ruby
599
+ # Inside a node's execute method
600
+ def execute(context, input)
601
+ log("Processing document", level: :info, metadata: { doc_id: input[:id] })
602
+ # ...
603
+ end
604
+
605
+ # Query logs
606
+ Orkestr::ExecutionLog.errors # error-level logs
607
+ Orkestr::ExecutionLog.for_level("info") # specific level
608
+ Orkestr::ExecutionLog.recent # ordered by created_at desc
609
+ ```
610
+
611
+ ## API Endpoints
612
+
613
+ All endpoints are namespaced under `/orkestr/api/`. When an `authenticator` is configured, requests must include valid credentials.
614
+
615
+ ### Workflows
616
+
617
+ | Method | Path | Description |
618
+ |---|---|---|
619
+ | `GET` | `/api/workflows` | List all workflows |
620
+ | `GET` | `/api/workflows/:id` | Show workflow with nodes & edges |
621
+ | `POST` | `/api/workflows` | Create a workflow |
622
+ | `PUT` | `/api/workflows/:id` | Update a workflow |
623
+ | `DELETE` | `/api/workflows/:id` | Delete a workflow |
624
+
625
+ ### Executions
626
+
627
+ | Method | Path | Description |
628
+ |---|---|---|
629
+ | `GET` | `/api/workflows/:workflow_id/executions` | List executions (filterable by `status`) |
630
+ | `GET` | `/api/executions/:id` | Show execution with node executions |
631
+ | `POST` | `/api/workflows/:workflow_id/executions` | Start a new execution |
632
+
633
+ The create endpoint accepts optional `contextualizable_type` and `contextualizable_id` parameters to attach the execution to a host app model.
634
+
635
+ ### Human Tasks
636
+
637
+ | Method | Path | Description |
638
+ |---|---|---|
639
+ | `GET` | `/api/human_tasks` | List tasks (filterable by `status`, `assignable_type`, `assignable_id`) |
640
+ | `GET` | `/api/human_tasks/:id` | Show task with schema & assignees |
641
+ | `PUT` | `/api/human_tasks/:id/complete` | Submit response and resume workflow |
642
+
643
+ ### Registry
644
+
645
+ | Method | Path | Description |
646
+ |---|---|---|
647
+ | `GET` | `/api/registry/nodes` | List available node plugins with schemas |
648
+ | `GET` | `/api/registry/triggers` | List available trigger plugins with schemas |
649
+
650
+ ### Webhooks
651
+
652
+ | Method | Path | Description |
653
+ |---|---|---|
654
+ | `POST` | `/webhooks/:workflow_id` | Trigger a workflow via webhook |
655
+
656
+ Webhook requests pass the request body as context. When a `secret` is configured on the trigger, the request must include a matching `X-Orkestr-Secret` header.
657
+
658
+ ## Creating Custom Plugins
659
+
660
+ ### Custom Node
661
+
662
+ Create a file in `app/orkestr_nodes/my_node/node.rb`:
663
+
664
+ ```ruby
665
+ module Orkestr
666
+ module Nodes
667
+ class MyNode < Base
668
+ node_id "my_node"
669
+ label "My Custom Node"
670
+ category "custom"
671
+
672
+ config_schema do
673
+ string :api_key, description: "API key for the service"
674
+ end
675
+
676
+ input_schema do
677
+ string :data, description: "Input data"
678
+ end
679
+
680
+ output_schema do
681
+ string :result, description: "Processed result"
682
+ end
683
+
684
+ def execute(context, input)
685
+ # Available helpers:
686
+ # config — node configuration (from config_json)
687
+ # execution — parent Execution record
688
+ # node_execution — current NodeExecution record
689
+ # workflow_context — execution context data
690
+ # log(message, level:, metadata:) — structured logging
691
+ # dig_value(hash, "nested.key") — dot-notation access
692
+ # interpolate(template, variables) — {{var}} interpolation
693
+ #
694
+ # Return a hash — it becomes the output passed to downstream nodes
695
+ { result: "processed" }
696
+ end
697
+ end
698
+ end
699
+ end
700
+ ```
701
+
702
+ Nodes are auto-discovered from:
703
+ 1. `orkestr/app/orkestr_nodes/` (built-in)
704
+ 2. `app/orkestr_nodes/` in the host app
705
+ 3. Paths in `Orkestr.configuration.nodes_paths`
706
+
707
+ ### Custom Trigger
708
+
709
+ Create a file in `app/orkestr_triggers/my_trigger/trigger.rb`:
710
+
711
+ ```ruby
712
+ module Orkestr
713
+ module Triggers
714
+ class MyTrigger < Base
715
+ trigger_id "my_trigger"
716
+ label "My Custom Trigger"
717
+
718
+ config_schema do
719
+ string :channel, description: "Channel to listen on"
720
+ end
721
+
722
+ def fire(context = {})
723
+ # Available helpers:
724
+ # workflow — associated Workflow record
725
+ # trigger_config — trigger configuration (symbolized keys)
726
+ # log(message, execution:, level:, metadata:) — structured logging
727
+ # start_execution(context, async:) — create and run execution
728
+ start_execution(context)
729
+ end
730
+ end
731
+ end
732
+ end
733
+ ```
734
+
735
+ ### Built-in Nodes
736
+
737
+ | Node | Category | Description |
738
+ |---|---|---|
739
+ | `action` | general | Execute Ruby code in a sandbox with access to `input`, `context`, and `output`. Optionally merge output into workflow context. 10s timeout |
740
+ | `transform` | data | Map, rename, and template data fields with dot-notation and `{{var}}` interpolation |
741
+ | `condition` | logic | Conditional branching with operators: `eq`, `neq`, `gt`, `lt`, `contains`, `present`, `blank`. Supports `match: all` or `match: any` for multiple rules |
742
+ | `http_request` | integration | HTTP requests (GET, POST, PUT, DELETE) with URL/body interpolation, configurable headers, 30s timeout |
743
+ | `wait` | flow | Pause execution until externally resumed via `ExecutionService::Complete` |
744
+ | `human_action` | human | Create a human task with form schema, assignees (polymorphic), deadlines, and wait for completion |
745
+
746
+ ### Built-in Triggers
747
+
748
+ | Trigger | Description |
749
+ |---|---|
750
+ | `manual` | Start a workflow on demand |
751
+ | `scheduled` | Cron-based scheduling (uses `fugit` for parsing) |
752
+ | `webhook` | HTTP webhook with optional secret verification (`X-Orkestr-Secret` header) |
753
+
754
+ ## Execution Engine
755
+
756
+ ### How it works
757
+
758
+ 1. **Start** — creates an `Execution` and `Context`, identifies start nodes (no incoming edges), creates initial `NodeExecution` records
759
+ 2. **Runner** — main loop: finds pending node executions, resolves join conditions, executes ready nodes in parallel batches via `Concurrent::Promises.future`
760
+ 3. **NodeRunner** — executes a single node through its plugin, handles waiting status
761
+ 4. **Enqueue next** — after a node completes, creates pending `NodeExecution` records for downstream nodes based on edge routing
762
+ 5. **Finalize** — when no more pending nodes, marks execution as completed/failed and updates context status
763
+
764
+ ### Edge routing
765
+
766
+ Edges can have a `source_handle` for conditional routing. When a node (e.g., Condition) outputs `{ result: "true" }`, only edges with a matching `source_handle` (or no handle) are followed.
767
+
768
+ ### Join semantics
769
+
770
+ Nodes can have `conditions_json: { join: "all" }` or `{ join: "any" }`:
771
+ - **join: all** — waits for all predecessor nodes to complete before executing
772
+ - **join: any** — executes as soon as any predecessor completes
773
+
774
+ ### Wait/Resume
775
+
776
+ Nodes can pause execution by setting their status to "waiting" (Wait node, HumanAction node). Resume with:
777
+
778
+ ```ruby
779
+ # Direct resume
780
+ Orkestr::ExecutionService::Complete.new(node_execution, output: { data: "value" }).call
781
+
782
+ # Via job (async)
783
+ Orkestr::ResumeExecutionJob.perform_later(execution.id, node_execution_id: ne.id, output: { data: "value" })
784
+ ```
785
+
786
+ ### Safety
787
+
788
+ - **Cycle detection** — `WorkflowSynchronizer` runs DFS-based cycle detection on graph sync (disabled when `allow_cycles` is true; Runner MAX_ITERATIONS=1000 still guards against runaway loops)
789
+ - **Max iterations** — Runner stops after 1000 iterations to prevent runaway loops
790
+ - **Unique constraint** — `(execution_id, node_id)` unique index prevents duplicate node executions
791
+ - **Query cache bypass** — Runner uses `ActiveRecord::Base.uncached` to avoid stale reads during concurrent execution
792
+
793
+ ## Database Schema
794
+
795
+ All tables use UUID primary keys (`pgcrypto`). Tables are prefixed with `orkestr_`.
796
+
797
+ | Table | Description |
798
+ |---|---|
799
+ | `orkestr_workflows` | Workflow definitions with graph, trigger config, defaults |
800
+ | `orkestr_nodes` | Node records synced from graph_json |
801
+ | `orkestr_edges` | Edge records synced from graph_json |
802
+ | `orkestr_contexts` | Execution contexts (polymorphic, linked to workflow) |
803
+ | `orkestr_executions` | Workflow execution instances |
804
+ | `orkestr_node_executions` | Per-node execution state with I/O data |
805
+ | `orkestr_human_tasks` | Human-in-the-loop tasks with form schema |
806
+ | `orkestr_assignees` | Polymorphic task assignments with deadlines |
807
+ | `orkestr_execution_logs` | Structured execution logs |
808
+
809
+ ## Scheduled Workflows
810
+
811
+ For scheduled workflows, add a cron job that runs every minute:
812
+
813
+ ```bash
814
+ * * * * * cd /path/to/app && bundle exec rake orkestr:check_scheduled
815
+ ```
816
+
817
+ ## Development
818
+
819
+ ```bash
820
+ # Start the database
821
+ ./bin/dev_db start
822
+
823
+ # Run the test suite (452 specs)
824
+ bundle exec rspec
825
+
826
+ # Run linter
827
+ bin/rubocop
828
+ ```
829
+
830
+ ## License
831
+
832
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).