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
@@ -0,0 +1,29 @@
1
+ module Orkestr
2
+ module WorkflowService
3
+ class Import
4
+ attr_reader :data
5
+
6
+ def initialize(data)
7
+ @data = data.deep_symbolize_keys
8
+ end
9
+
10
+ def call
11
+ Workflow.transaction do
12
+ workflow = Workflow.create!(
13
+ name: data[:name] || "Imported workflow",
14
+ status: "draft",
15
+ trigger_type: data[:trigger_type],
16
+ trigger_config: data[:trigger_config] || {},
17
+ default_context: data[:default_context] || {},
18
+ reuse_context: data[:reuse_context] || false,
19
+ graph_json: data[:graph_json] || {}
20
+ )
21
+
22
+ WorkflowSynchronizer.new(workflow).call if workflow.graph_json.present?
23
+
24
+ workflow
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,102 @@
1
+ module Orkestr
2
+ class WorkflowSynchronizer
3
+ attr_reader :workflow
4
+
5
+ def initialize(workflow)
6
+ @workflow = workflow
7
+ end
8
+
9
+ def call
10
+ graph = workflow.graph
11
+ flow_nodes = graph[:nodes] || []
12
+ flow_edges = graph[:edges] || []
13
+
14
+ ActiveRecord::Base.transaction do
15
+ remove_stale_edges(flow_edges)
16
+ remove_stale_nodes(flow_nodes)
17
+ upsert_nodes(flow_nodes)
18
+ upsert_edges(flow_edges)
19
+ detect_cycles! unless Orkestr.configuration.allow_cycles
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def remove_stale_edges(flow_edges)
26
+ flow_ids = flow_edges.map { |e| e[:id].to_s }
27
+ workflow.edges.where.not(react_flow_id: flow_ids).destroy_all
28
+ end
29
+
30
+ def remove_stale_nodes(flow_nodes)
31
+ flow_ids = flow_nodes.map { |n| n[:id].to_s }
32
+ workflow.nodes.where.not(react_flow_id: flow_ids).destroy_all
33
+ end
34
+
35
+ # Fields that are internal to the React Flow UI and should not be persisted in config_json
36
+ UI_INTERNAL_KEYS = %i[type label executionStatus].freeze
37
+
38
+ def upsert_nodes(flow_nodes)
39
+ flow_nodes.each do |flow_node|
40
+ data = (flow_node[:data] || {}).deep_dup
41
+ conditions = data.delete(:conditions) || {}
42
+ UI_INTERNAL_KEYS.each { |key| data.delete(key) }
43
+ node = workflow.nodes.find_or_initialize_by(react_flow_id: flow_node[:id].to_s)
44
+ node.update!(
45
+ node_type: flow_node[:type] || flow_node.dig(:data, :type) || "default",
46
+ config_json: data,
47
+ conditions_json: conditions
48
+ )
49
+ end
50
+ end
51
+
52
+ def upsert_edges(flow_edges)
53
+ flow_edges.each do |flow_edge|
54
+ source = workflow.nodes.find_by!(react_flow_id: flow_edge[:source].to_s)
55
+ target = workflow.nodes.find_by!(react_flow_id: flow_edge[:target].to_s)
56
+
57
+ edge = workflow.edges.find_or_initialize_by(react_flow_id: flow_edge[:id].to_s)
58
+ edge.update!(
59
+ source_node: source,
60
+ target_node: target,
61
+ source_handle: flow_edge[:sourceHandle]
62
+ )
63
+ end
64
+ end
65
+
66
+ def detect_cycles!
67
+ nodes = workflow.nodes.reload.index_by(&:id)
68
+ adjacency = Hash.new { |h, k| h[k] = [] }
69
+
70
+ workflow.edges.reload.each do |edge|
71
+ adjacency[edge.source_node_id] << edge.target_node_id
72
+ end
73
+
74
+ visited = Set.new
75
+ in_stack = Set.new
76
+
77
+ nodes.each_key do |node_id|
78
+ next if visited.include?(node_id)
79
+
80
+ if dfs_has_cycle?(node_id, adjacency, visited, in_stack)
81
+ raise Orkestr::CycleDetectedError, "Workflow graph contains a cycle"
82
+ end
83
+ end
84
+ end
85
+
86
+ def dfs_has_cycle?(node_id, adjacency, visited, in_stack)
87
+ visited.add(node_id)
88
+ in_stack.add(node_id)
89
+
90
+ adjacency[node_id].each do |neighbor|
91
+ if in_stack.include?(neighbor)
92
+ return true
93
+ elsif !visited.include?(neighbor)
94
+ return true if dfs_has_cycle?(neighbor, adjacency, visited, in_stack)
95
+ end
96
+ end
97
+
98
+ in_stack.delete(node_id)
99
+ false
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Orkestr</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "orkestr/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Orkestr</title>
7
+ <style>html, body { margin: 0; padding: 0; height: 100%; }</style>
8
+ <% Orkestr.configuration.custom_ui_styles.each do |style_path| %>
9
+ <%= stylesheet_link_tag style_path %>
10
+ <% end %>
11
+ </head>
12
+ <body>
13
+ <%= yield %>
14
+ <% Orkestr.configuration.custom_ui_scripts.each do |script_path| %>
15
+ <%= javascript_include_tag script_path %>
16
+ <% end %>
17
+ </body>
18
+ </html>
@@ -0,0 +1,17 @@
1
+ <%# Standalone task form page — uses the web component.
2
+ Host apps can also embed <orkestr-editor mode="task" task-id="...">
3
+ inside their own containers (modals, panels, etc.) %>
4
+ <%= javascript_include_tag "orkestr/orkestr-editor", defer: true %>
5
+
6
+ <div style="max-width:40rem;margin:2rem auto;padding:0 1rem;">
7
+ <h2 style="font-size:1.25rem;font-weight:600;margin-bottom:1rem;">
8
+ <%= @task.node&.config_json&.dig("description") || @task.node&.config_json&.dig("label") || "Complete Task" %>
9
+ </h2>
10
+
11
+ <orkestr-editor
12
+ api-base-url="<%= request.path.sub(%r{/human_tasks.*$}, "") %>"
13
+ mode="task"
14
+ task-id="<%= @task.id %>"
15
+ style="display:block;min-height:200px;">
16
+ </orkestr-editor>
17
+ </div>
@@ -0,0 +1,8 @@
1
+ <%= javascript_include_tag "orkestr/orkestr-editor", defer: true %>
2
+
3
+ <% api_base = request.path.sub(%r{/ui.*$}, "") %>
4
+ <orkestr-editor
5
+ api-base-url="<%= api_base %>"
6
+ mode="dashboard"
7
+ style="display: block; width: 100%; height: 100vh;">
8
+ </orkestr-editor>
data/config/routes.rb ADDED
@@ -0,0 +1,27 @@
1
+ Orkestr::Engine.routes.draw do
2
+ post "webhooks/:workflow_id", to: "webhooks#create", as: :webhook
3
+
4
+ namespace :api do
5
+ resources :workflows, only: %i[index show create update destroy] do
6
+ resources :executions, only: %i[index create]
7
+ end
8
+ resources :executions, only: %i[show]
9
+ resources :human_tasks, only: %i[index show] do
10
+ member do
11
+ put :complete
12
+ end
13
+ end
14
+ get "registry/nodes", to: "registry#nodes"
15
+ get "registry/triggers", to: "registry#triggers"
16
+ end
17
+
18
+ # HTML endpoints for embedding human task forms in host apps (turbo frame compatible)
19
+ resources :human_tasks, only: %i[show] do
20
+ member do
21
+ put :complete
22
+ end
23
+ end
24
+
25
+ get "ui", to: "ui#index"
26
+ get "ui/*path", to: "ui#index"
27
+ end
@@ -0,0 +1,5 @@
1
+ class EnablePgcryptoExtension < ActiveRecord::Migration[8.0]
2
+ def change
3
+ enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ class CreateOrkestrWorkflows < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_workflows, id: :uuid do |t|
4
+ t.string :name, null: false
5
+ t.json :graph_json, null: false, default: {}
6
+ t.string :trigger_type
7
+ t.json :trigger_config, null: false, default: {}
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class CreateOrkestrNodes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_nodes, id: :uuid do |t|
4
+ t.references :workflow, null: false, foreign_key: { to_table: :orkestr_workflows }, type: :uuid
5
+ t.string :node_type, null: false
6
+ t.json :config_json, null: false, default: {}
7
+ t.json :conditions_json, null: false, default: {}
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class CreateOrkestrEdges < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_edges, id: :uuid do |t|
4
+ t.references :workflow, null: false, foreign_key: { to_table: :orkestr_workflows }, type: :uuid
5
+ t.references :source_node, null: false, foreign_key: { to_table: :orkestr_nodes }, type: :uuid
6
+ t.references :target_node, null: false, foreign_key: { to_table: :orkestr_nodes }, type: :uuid
7
+ t.string :source_handle
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOrkestrExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_executions, id: :uuid do |t|
4
+ t.references :executable, polymorphic: true, null: true, type: :uuid
5
+ t.json :context_json, null: false, default: {}
6
+ t.string :status, null: false, default: "pending"
7
+ t.datetime :started_at
8
+ t.datetime :finished_at
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ class CreateOrkestrNodeExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_node_executions, id: :uuid do |t|
4
+ t.references :execution, null: false, foreign_key: { to_table: :orkestr_executions }, type: :uuid
5
+ t.references :node, null: false, foreign_key: { to_table: :orkestr_nodes }, type: :uuid
6
+ t.string :status, null: false, default: "pending"
7
+ t.json :input_json, null: false, default: {}
8
+ t.json :output_json, null: false, default: {}
9
+ t.datetime :started_at
10
+ t.datetime :finished_at
11
+ t.integer :attempt, null: false, default: 1
12
+
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ class AddReactFlowIdToNodes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :orkestr_nodes, :react_flow_id, :string
4
+ add_index :orkestr_nodes, :react_flow_id
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class AddReactFlowIdToEdges < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :orkestr_edges, :react_flow_id, :string
4
+ add_index :orkestr_edges, :react_flow_id
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddWorkflowToExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :orkestr_executions, :workflow, null: false, foreign_key: { to_table: :orkestr_workflows }, type: :uuid
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class MakeExecutableNullableOnExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column_null :orkestr_executions, :executable_id, true
4
+ change_column_null :orkestr_executions, :executable_type, true
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ class CreateOrkestrHumanTasks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_human_tasks, id: :uuid do |t|
4
+ t.references :node_execution, null: false, foreign_key: { to_table: :orkestr_node_executions }, type: :uuid
5
+ t.references :execution, null: false, foreign_key: { to_table: :orkestr_executions }, type: :uuid
6
+ t.references :node, null: false, foreign_key: { to_table: :orkestr_nodes }, type: :uuid
7
+ t.json :schema_json, null: false, default: {}
8
+ t.json :response_json
9
+ t.string :status, null: false, default: "pending"
10
+ t.datetime :completed_at
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOrkestrAssignees < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_assignees, id: :uuid do |t|
4
+ t.references :human_task, null: false, foreign_key: { to_table: :orkestr_human_tasks }, type: :uuid
5
+ t.string :assignable_type, null: false
6
+ t.string :assignable_id, null: false
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :orkestr_assignees, %i[assignable_type assignable_id]
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ class AddUniqueIndexToNodeExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_index :orkestr_node_executions, %i[execution_id node_id],
4
+ unique: true,
5
+ name: "index_orkestr_node_executions_unique_per_execution"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ class CreateOrkestrContexts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_contexts, id: :uuid do |t|
4
+ t.references :contextualizable, polymorphic: true, null: true, type: :uuid
5
+ t.jsonb :data, null: false, default: {}
6
+
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ class ReplaceExecutableWithContextOnExecutions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :orkestr_executions, :context, null: true, type: :uuid,
4
+ foreign_key: { to_table: :orkestr_contexts }
5
+
6
+ remove_index :orkestr_executions, name: :index_orkestr_executions_on_executable
7
+ remove_column :orkestr_executions, :executable_type, :string
8
+ remove_column :orkestr_executions, :executable_id, :uuid
9
+ remove_column :orkestr_executions, :context_json, :json, null: false, default: {}
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class AddStatusKeyDeadlineToOrkestrAssignees < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :orkestr_assignees, :key, :string
4
+ add_column :orkestr_assignees, :status, :string, null: false, default: "pending"
5
+ add_column :orkestr_assignees, :deadline, :datetime
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddStatusAndWorkflowToOrkestrContexts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :orkestr_contexts, :status, :string, null: false, default: "pending"
4
+ add_reference :orkestr_contexts, :workflow, type: :uuid, null: true,
5
+ foreign_key: { to_table: :orkestr_workflows }
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddStatusDefaultContextAndReuseContextToOrkestrWorkflows < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :orkestr_workflows, :status, :string, null: false, default: "draft"
4
+ add_column :orkestr_workflows, :default_context, :jsonb, null: false, default: {}
5
+ add_column :orkestr_workflows, :reuse_context, :boolean, null: false, default: false
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ class CreateOrkestrExecutionLogs < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :orkestr_execution_logs, id: :uuid do |t|
4
+ t.references :execution, null: false, type: :uuid,
5
+ foreign_key: { to_table: :orkestr_executions }
6
+ t.references :node_execution, null: true, type: :uuid,
7
+ foreign_key: { to_table: :orkestr_node_executions }
8
+ t.string :level, null: false, default: "info"
9
+ t.text :message, null: false
10
+ t.jsonb :metadata, null: false, default: {}
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ class ReplaceNodeExecutionsUniqueIndexForCycleSupport < ActiveRecord::Migration[8.0]
2
+ def up
3
+ remove_index :orkestr_node_executions, name: "index_orkestr_node_executions_unique_per_execution"
4
+
5
+ add_index :orkestr_node_executions, [:execution_id, :node_id],
6
+ unique: true,
7
+ where: "status IN ('pending', 'running', 'waiting')",
8
+ name: "index_orkestr_node_executions_unique_active"
9
+ end
10
+
11
+ def down
12
+ remove_index :orkestr_node_executions, name: "index_orkestr_node_executions_unique_active"
13
+
14
+ add_index :orkestr_node_executions, [:execution_id, :node_id],
15
+ unique: true,
16
+ name: "index_orkestr_node_executions_unique_per_execution"
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ module Orkestr
2
+ class Configuration
3
+ attr_accessor :nodes_paths, :triggers_paths, :authenticator, :authorizer, :custom_ui_scripts, :custom_ui_styles,
4
+ :allow_cycles
5
+
6
+ def initialize
7
+ @nodes_paths = []
8
+ @triggers_paths = []
9
+ @authenticator = nil
10
+ @authorizer = nil
11
+ @custom_ui_scripts = []
12
+ @custom_ui_styles = []
13
+ @allow_cycles = false
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ module Orkestr
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Orkestr
4
+
5
+ config.generators do |g|
6
+ g.orm :active_record, primary_key_type: :uuid
7
+ end
8
+
9
+ # Register app/assets/builds so Propshaft finds orkestr/orkestr-editor.js
10
+ config.paths["app/assets"] << "app/assets/builds"
11
+
12
+ initializer "orkestr.assets", before: :append_assets_path do |app|
13
+ builds_path = root.join("app/assets/builds")
14
+ app.config.assets.paths.unshift(builds_path) if builds_path.exist?
15
+ end
16
+
17
+ initializer "orkestr.append_migrations" do |app|
18
+ unless app.root.to_s.match?(root.to_s)
19
+ config.paths["db/migrate"].expanded.each do |path|
20
+ app.config.paths["db/migrate"] << path
21
+ end
22
+ end
23
+ end
24
+
25
+ # Exclude plugin directories from Zeitwerk autoloading.
26
+ # They use a custom loader (Orkestr::Nodes::Loader / Orkestr::Triggers::Loader).
27
+ initializer "orkestr.ignore_plugin_paths", before: :set_autoload_paths do
28
+ Rails.autoloaders.main.ignore(
29
+ root.join("app/orkestr_nodes"),
30
+ root.join("app/orkestr_triggers")
31
+ )
32
+ end
33
+
34
+ initializer "orkestr.helpers" do
35
+ ActiveSupport.on_load(:action_view) do
36
+ require_relative "../../app/helpers/orkestr/ui_helper"
37
+ include Orkestr::UiHelper
38
+ end
39
+ end
40
+
41
+ initializer "orkestr.load_plugins" do
42
+ config.after_initialize do
43
+ Orkestr::Nodes::Loader.load_all!
44
+ Orkestr::Triggers::Loader.load_all!
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "schema_dsl"
2
+ require_relative "registry"
3
+
4
+ module Orkestr
5
+ module Nodes
6
+ class Base
7
+ include SchemaDsl
8
+
9
+ def self.inherited(subclass)
10
+ super
11
+ TracePoint.new(:end) do |tp|
12
+ if tp.self == subclass
13
+ tp.disable
14
+ Registry.register(subclass) if subclass.node_id
15
+ end
16
+ end.enable
17
+ end
18
+
19
+ attr_reader :node_execution
20
+
21
+ def initialize(node_execution, resolved_config: nil)
22
+ @node_execution = node_execution
23
+ @resolved_config = resolved_config
24
+ end
25
+
26
+ def execute(_context, _input)
27
+ raise NotImplementedError, "#{self.class.name} must implement #execute"
28
+ end
29
+
30
+ def config
31
+ @resolved_config || node_execution.node.config
32
+ end
33
+
34
+ def execution
35
+ node_execution.execution
36
+ end
37
+
38
+ def workflow_context
39
+ execution.context_data
40
+ end
41
+
42
+ def log(message, level: :info, metadata: {})
43
+ ExecutionLog.create!(
44
+ execution: execution,
45
+ node_execution: node_execution,
46
+ level: level.to_s,
47
+ message: message,
48
+ metadata: metadata
49
+ )
50
+ end
51
+
52
+ protected
53
+
54
+ def dig_value(data, path)
55
+ return nil if path.blank?
56
+
57
+ keys = path.to_s.split(".")
58
+ keys.reduce(data) do |obj, key|
59
+ case obj
60
+ when Hash then obj[key.to_sym] || obj[key.to_s]
61
+ else nil
62
+ end
63
+ end
64
+ end
65
+
66
+ def interpolate(template, data)
67
+ template.gsub(/\{\{(\s*[\w.]+\s*)\}\}/) do
68
+ path = ::Regexp.last_match(1).strip
69
+ dig_value(data, path).to_s
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,32 @@
1
+ module Orkestr
2
+ module Nodes
3
+ module Loader
4
+ class << self
5
+ def load_all!
6
+ search_paths.each do |path|
7
+ load_from(path)
8
+ end
9
+ end
10
+
11
+ private
12
+
13
+ def search_paths
14
+ paths = []
15
+ # Engine built-in nodes
16
+ paths << Orkestr::Engine.root.join("app", "orkestr_nodes")
17
+ # Configured custom paths
18
+ Orkestr.configuration.nodes_paths.each { |p| paths << Pathname.new(p) }
19
+ # Host app nodes
20
+ paths << Rails.root.join("app", "orkestr_nodes") if defined?(Rails)
21
+ paths.select(&:exist?).uniq
22
+ end
23
+
24
+ def load_from(base_path)
25
+ Dir[base_path.join("**/node.rb")].sort.each do |file|
26
+ require file
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ module Orkestr
2
+ module Nodes
3
+ module Registry
4
+ class << self
5
+ def register(node_class)
6
+ node_class.validate_node_id!
7
+ registry[node_class.node_id] = node_class
8
+ end
9
+
10
+ def find(id)
11
+ registry[id]
12
+ end
13
+
14
+ def all
15
+ registry.values
16
+ end
17
+
18
+ def ids
19
+ registry.keys
20
+ end
21
+
22
+ def reset!
23
+ @registry = {}
24
+ end
25
+
26
+ private
27
+
28
+ def registry
29
+ @registry ||= {}
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end