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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +832 -0
- data/Rakefile +6 -0
- data/app/assets/builds/orkestr/orkestr-editor.js +72 -0
- data/app/assets/stylesheets/orkestr/application.css +15 -0
- data/app/assets/stylesheets/orkestr/theme.css +62 -0
- data/app/controllers/orkestr/api/base_controller.rb +45 -0
- data/app/controllers/orkestr/api/executions_controller.rb +50 -0
- data/app/controllers/orkestr/api/human_tasks_controller.rb +48 -0
- data/app/controllers/orkestr/api/registry_controller.rb +40 -0
- data/app/controllers/orkestr/api/workflows_controller.rb +53 -0
- data/app/controllers/orkestr/application_controller.rb +4 -0
- data/app/controllers/orkestr/human_tasks_controller.rb +30 -0
- data/app/controllers/orkestr/ui_controller.rb +8 -0
- data/app/controllers/orkestr/webhooks_controller.rb +35 -0
- data/app/helpers/orkestr/application_helper.rb +4 -0
- data/app/helpers/orkestr/ui_helper.rb +34 -0
- data/app/javascript/orkestr-ui/index.html +19 -0
- data/app/javascript/orkestr-ui/package-lock.json +2050 -0
- data/app/javascript/orkestr-ui/package.json +23 -0
- data/app/javascript/orkestr-ui/src/OrkestrApp.tsx +152 -0
- data/app/javascript/orkestr-ui/src/api/client.ts +59 -0
- data/app/javascript/orkestr-ui/src/api/executions.ts +23 -0
- data/app/javascript/orkestr-ui/src/api/humanTasks.ts +32 -0
- data/app/javascript/orkestr-ui/src/api/index.ts +5 -0
- data/app/javascript/orkestr-ui/src/api/registry.ts +10 -0
- data/app/javascript/orkestr-ui/src/api/workflows.ts +33 -0
- data/app/javascript/orkestr-ui/src/components/Editor/ActionsBuilder.tsx +213 -0
- data/app/javascript/orkestr-ui/src/components/Editor/CustomNode.tsx +31 -0
- data/app/javascript/orkestr-ui/src/components/Editor/EditorToolbar.tsx +153 -0
- data/app/javascript/orkestr-ui/src/components/Editor/FormSchemaBuilder.tsx +390 -0
- data/app/javascript/orkestr-ui/src/components/Editor/NodeConfigPanel.tsx +274 -0
- data/app/javascript/orkestr-ui/src/components/Editor/NodePalette.tsx +43 -0
- data/app/javascript/orkestr-ui/src/components/Editor/RunDialog.tsx +52 -0
- data/app/javascript/orkestr-ui/src/components/Editor/WorkflowEditor.tsx +299 -0
- data/app/javascript/orkestr-ui/src/components/Executions/ExecutionDetail.tsx +155 -0
- data/app/javascript/orkestr-ui/src/components/Executions/ExecutionList.tsx +74 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskForm.tsx +216 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskFormEmbed.tsx +117 -0
- data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskList.tsx +110 -0
- data/app/javascript/orkestr-ui/src/components/Workflows/NewWorkflowDialog.tsx +64 -0
- data/app/javascript/orkestr-ui/src/components/Workflows/WorkflowList.tsx +94 -0
- data/app/javascript/orkestr-ui/src/components/shared/EntryConditionsEditor.tsx +138 -0
- data/app/javascript/orkestr-ui/src/components/shared/ExpressionEditor.tsx +206 -0
- data/app/javascript/orkestr-ui/src/components/shared/HumanTaskFormRenderer.tsx +321 -0
- data/app/javascript/orkestr-ui/src/components/shared/JsonSchemaForm.tsx +376 -0
- data/app/javascript/orkestr-ui/src/components/shared/Loading.tsx +5 -0
- data/app/javascript/orkestr-ui/src/components/shared/StatusBadge.tsx +9 -0
- data/app/javascript/orkestr-ui/src/fieldRegistry.ts +74 -0
- data/app/javascript/orkestr-ui/src/hooks/useApi.ts +30 -0
- data/app/javascript/orkestr-ui/src/hooks/useRegistry.ts +35 -0
- data/app/javascript/orkestr-ui/src/main.tsx +75 -0
- data/app/javascript/orkestr-ui/src/styles/editor.css +445 -0
- data/app/javascript/orkestr-ui/src/styles/index.css +478 -0
- data/app/javascript/orkestr-ui/src/types/execution.ts +37 -0
- data/app/javascript/orkestr-ui/src/types/humanTask.ts +30 -0
- data/app/javascript/orkestr-ui/src/types/index.ts +4 -0
- data/app/javascript/orkestr-ui/src/types/registry.ts +22 -0
- data/app/javascript/orkestr-ui/src/types/workflow.ts +64 -0
- data/app/javascript/orkestr-ui/src/vite-env.d.ts +6 -0
- data/app/javascript/orkestr-ui/tsconfig.json +21 -0
- data/app/javascript/orkestr-ui/tsconfig.tsbuildinfo +1 -0
- data/app/javascript/orkestr-ui/vite.config.ts +30 -0
- data/app/jobs/orkestr/application_job.rb +4 -0
- data/app/jobs/orkestr/execute_workflow_job.rb +10 -0
- data/app/jobs/orkestr/resume_execution_job.rb +15 -0
- data/app/mailers/orkestr/application_mailer.rb +6 -0
- data/app/models/concerns/orkestr/assignable.rb +28 -0
- data/app/models/concerns/orkestr/contextualizable.rb +9 -0
- data/app/models/orkestr/application_record.rb +6 -0
- data/app/models/orkestr/assignee.rb +40 -0
- data/app/models/orkestr/context.rb +42 -0
- data/app/models/orkestr/edge.rb +58 -0
- data/app/models/orkestr/execution.rb +45 -0
- data/app/models/orkestr/execution_log.rb +38 -0
- data/app/models/orkestr/human_task.rb +63 -0
- data/app/models/orkestr/node.rb +48 -0
- data/app/models/orkestr/node_execution.rb +59 -0
- data/app/models/orkestr/workflow.rb +39 -0
- data/app/orkestr_nodes/action/node.rb +77 -0
- data/app/orkestr_nodes/condition/node.rb +67 -0
- data/app/orkestr_nodes/http_request/node.rb +88 -0
- data/app/orkestr_nodes/human_action/node.rb +103 -0
- data/app/orkestr_nodes/transform/node.rb +48 -0
- data/app/orkestr_nodes/wait/node.rb +18 -0
- data/app/orkestr_triggers/manual/trigger.rb +12 -0
- data/app/orkestr_triggers/scheduled/trigger.rb +26 -0
- data/app/orkestr_triggers/webhook/trigger.rb +23 -0
- data/app/serializers/orkestr/assignee_serializer.rb +30 -0
- data/app/serializers/orkestr/context_serializer.rb +30 -0
- data/app/serializers/orkestr/edge_serializer.rb +33 -0
- data/app/serializers/orkestr/execution_collection_serializer.rb +7 -0
- data/app/serializers/orkestr/execution_log_serializer.rb +31 -0
- data/app/serializers/orkestr/execution_serializer.rb +37 -0
- data/app/serializers/orkestr/human_task_serializer.rb +46 -0
- data/app/serializers/orkestr/node_execution_serializer.rb +43 -0
- data/app/serializers/orkestr/node_serializer.rb +29 -0
- data/app/serializers/orkestr/workflow_collection_serializer.rb +11 -0
- data/app/serializers/orkestr/workflow_serializer.rb +30 -0
- data/app/services/orkestr/entry_condition_evaluator.rb +68 -0
- data/app/services/orkestr/execution_service/complete.rb +64 -0
- data/app/services/orkestr/execution_service/join_resolver.rb +56 -0
- data/app/services/orkestr/execution_service/node_runner.rb +56 -0
- data/app/services/orkestr/execution_service/runner.rb +162 -0
- data/app/services/orkestr/execution_service/start.rb +90 -0
- data/app/services/orkestr/expression_resolver.rb +72 -0
- data/app/services/orkestr/human_task_service/complete.rb +62 -0
- data/app/services/orkestr/workflow_service/duplicate.rb +30 -0
- data/app/services/orkestr/workflow_service/export.rb +26 -0
- data/app/services/orkestr/workflow_service/import.rb +29 -0
- data/app/services/orkestr/workflow_synchronizer.rb +102 -0
- data/app/views/layouts/orkestr/application.html.erb +17 -0
- data/app/views/layouts/orkestr/ui.html.erb +18 -0
- data/app/views/orkestr/human_tasks/show.html.erb +17 -0
- data/app/views/orkestr/ui/index.html.erb +8 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260308204133_enable_pgcrypto_extension.rb +5 -0
- data/db/migrate/20260308204558_create_orkestr_workflows.rb +12 -0
- data/db/migrate/20260308204703_create_orkestr_nodes.rb +12 -0
- data/db/migrate/20260308204807_create_orkestr_edges.rb +12 -0
- data/db/migrate/20260308204931_create_orkestr_executions.rb +13 -0
- data/db/migrate/20260308205023_create_orkestr_node_executions.rb +16 -0
- data/db/migrate/20260308205119_add_react_flow_id_to_nodes.rb +6 -0
- data/db/migrate/20260308205123_add_react_flow_id_to_edges.rb +6 -0
- data/db/migrate/20260308205745_add_workflow_to_executions.rb +5 -0
- data/db/migrate/20260308205940_make_executable_nullable_on_executions.rb +6 -0
- data/db/migrate/20260308220730_create_orkestr_human_tasks.rb +15 -0
- data/db/migrate/20260308220900_create_orkestr_assignees.rb +13 -0
- data/db/migrate/20260308234115_add_unique_index_to_node_executions.rb +7 -0
- data/db/migrate/20260309075336_create_orkestr_contexts.rb +10 -0
- data/db/migrate/20260309075343_replace_executable_with_context_on_executions.rb +11 -0
- data/db/migrate/20260309080416_add_status_key_deadline_to_orkestr_assignees.rb +7 -0
- data/db/migrate/20260309082815_add_status_and_workflow_to_orkestr_contexts.rb +7 -0
- data/db/migrate/20260309082816_add_status_default_context_and_reuse_context_to_orkestr_workflows.rb +7 -0
- data/db/migrate/20260309083328_create_orkestr_execution_logs.rb +14 -0
- data/db/migrate/20260310223204_replace_node_executions_unique_index_for_cycle_support.rb +18 -0
- data/lib/orkestr/configuration.rb +16 -0
- data/lib/orkestr/engine.rb +48 -0
- data/lib/orkestr/nodes/base.rb +74 -0
- data/lib/orkestr/nodes/loader.rb +32 -0
- data/lib/orkestr/nodes/registry.rb +34 -0
- data/lib/orkestr/nodes/schema_dsl.rb +73 -0
- data/lib/orkestr/triggers/base.rb +49 -0
- data/lib/orkestr/triggers/loader.rb +29 -0
- data/lib/orkestr/triggers/registry.rb +34 -0
- data/lib/orkestr/triggers/schema_dsl.rb +45 -0
- data/lib/orkestr/version.rb +3 -0
- data/lib/orkestr.rb +27 -0
- data/lib/tasks/annotate_rb.rake +10 -0
- data/lib/tasks/orkestr_tasks.rake +19 -0
- 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
|
+
|  |  |  |
|
|
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).
|