durable_workflow 0.1.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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. metadata +275 -0
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ # Module to hold dynamically generated RubyLLM::Tool classes
7
+ module GeneratedTools
8
+ end
9
+
10
+ # Message role enum (AI-specific, not in core)
11
+ module Types
12
+ MessageRole = DurableWorkflow::Types::Strict::String.enum('system', 'user', 'assistant', 'tool')
13
+ end
14
+
15
+ # Handoff definition
16
+ class HandoffDef < BaseStruct
17
+ attribute :agent_id, DurableWorkflow::Types::Strict::String
18
+ attribute? :description, DurableWorkflow::Types::Strict::String.optional
19
+ end
20
+
21
+ # Agent definition
22
+ class AgentDef < BaseStruct
23
+ attribute :id, DurableWorkflow::Types::Strict::String
24
+ attribute? :name, DurableWorkflow::Types::Strict::String.optional
25
+ attribute :model, DurableWorkflow::Types::Strict::String
26
+ attribute? :instructions, DurableWorkflow::Types::Strict::String.optional
27
+ attribute :tools, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
28
+ attribute :handoffs, DurableWorkflow::Types::Strict::Array.of(HandoffDef).default([].freeze)
29
+ end
30
+
31
+ # Tool parameter
32
+ class ToolParam < BaseStruct
33
+ attribute :name, DurableWorkflow::Types::Strict::String
34
+ attribute? :type, DurableWorkflow::Types::Strict::String.optional.default('string')
35
+ attribute? :required, DurableWorkflow::Types::Strict::Bool.default(true)
36
+ attribute? :description, DurableWorkflow::Types::Strict::String.optional
37
+ end
38
+
39
+ # Tool definition
40
+ class ToolDef < BaseStruct
41
+ attribute :id, DurableWorkflow::Types::Strict::String
42
+ attribute :description, DurableWorkflow::Types::Strict::String
43
+ attribute :parameters, DurableWorkflow::Types::Strict::Array.of(ToolParam).default([].freeze)
44
+ attribute :service, DurableWorkflow::Types::Strict::String
45
+ attribute :method_name, DurableWorkflow::Types::Strict::String
46
+
47
+ def to_function_schema
48
+ {
49
+ name: id,
50
+ description:,
51
+ parameters: {
52
+ type: 'object',
53
+ properties: parameters.each_with_object({}) do |p, h|
54
+ h[p.name] = { type: p.type, description: p.description }.compact
55
+ end,
56
+ required: parameters.select(&:required).map(&:name)
57
+ }
58
+ }
59
+ end
60
+
61
+ # Convert to RubyLLM::Tool class
62
+ def to_ruby_llm_tool
63
+ tool_def = self
64
+ class_name = id.split('_').map(&:capitalize).join
65
+ short_name = id # Use the tool id as the name (e.g., "classify_request")
66
+
67
+ # Create named class under GeneratedTools module
68
+ AI::GeneratedTools.const_set(class_name, Class.new(RubyLLM::Tool) do
69
+ # Store reference to original definition
70
+ @tool_def = tool_def
71
+
72
+ # Set description
73
+ description tool_def.description
74
+
75
+ # Override name to avoid long namespace in tool name
76
+ define_method(:name) { short_name }
77
+
78
+ # Define parameters
79
+ tool_def.parameters.each do |p|
80
+ param p.name.to_sym,
81
+ type: p.type.to_sym,
82
+ desc: p.description,
83
+ required: p.required
84
+ end
85
+
86
+ # Execute calls the service method
87
+ define_method(:execute) do |**args|
88
+ svc = Object.const_get(tool_def.service)
89
+ svc.public_send(tool_def.method_name, **args)
90
+ end
91
+
92
+ class << self
93
+ attr_reader :tool_def
94
+ end
95
+ end)
96
+ end
97
+ end
98
+
99
+ # Tool call from LLM
100
+ class ToolCall < BaseStruct
101
+ attribute :id, DurableWorkflow::Types::Strict::String
102
+ attribute :name, DurableWorkflow::Types::Strict::String
103
+ attribute :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
104
+ end
105
+
106
+ # Message in conversation
107
+ class Message < BaseStruct
108
+ attribute :role, Types::MessageRole
109
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
110
+ attribute? :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).optional
111
+ attribute? :tool_call_id, DurableWorkflow::Types::Strict::String.optional
112
+ attribute? :name, DurableWorkflow::Types::Strict::String.optional
113
+
114
+ def self.system(content)
115
+ new(role: 'system', content:)
116
+ end
117
+
118
+ def self.user(content)
119
+ new(role: 'user', content:)
120
+ end
121
+
122
+ def self.assistant(content, tool_calls: nil)
123
+ new(role: 'assistant', content:, tool_calls:)
124
+ end
125
+
126
+ def self.tool(content, tool_call_id:, name: nil)
127
+ new(role: 'tool', content:, tool_call_id:, name:)
128
+ end
129
+
130
+ def system? = role == 'system'
131
+ def user? = role == 'user'
132
+ def assistant? = role == 'assistant'
133
+ def tool? = role == 'tool'
134
+ def tool_calls? = tool_calls&.any?
135
+ end
136
+
137
+ # LLM response
138
+ class Response < BaseStruct
139
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
140
+ attribute :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).default([].freeze)
141
+ attribute? :finish_reason, DurableWorkflow::Types::Strict::String.optional
142
+ attribute? :usage, DurableWorkflow::Types::Hash.optional
143
+
144
+ def tool_calls? = tool_calls.any?
145
+ end
146
+
147
+ # Moderation result
148
+ class ModerationResult < BaseStruct
149
+ attribute :flagged, DurableWorkflow::Types::Strict::Bool
150
+ attribute? :categories, DurableWorkflow::Types::Hash.optional
151
+ attribute? :scores, DurableWorkflow::Types::Hash.optional
152
+ end
153
+
154
+ # Guardrail check
155
+ class GuardrailCheck < BaseStruct
156
+ attribute :type, DurableWorkflow::Types::Strict::String
157
+ attribute? :pattern, DurableWorkflow::Types::Strict::String.optional
158
+ attribute? :block_on_match, DurableWorkflow::Types::Strict::Bool.default(true)
159
+ attribute? :max, DurableWorkflow::Types::Strict::Integer.optional
160
+ attribute? :min, DurableWorkflow::Types::Strict::Integer.optional
161
+ end
162
+
163
+ # Guardrail result
164
+ class GuardrailResult < BaseStruct
165
+ attribute :passed, DurableWorkflow::Types::Strict::Bool
166
+ attribute :check_type, DurableWorkflow::Types::Strict::String
167
+ attribute? :reason, DurableWorkflow::Types::Strict::String.optional
168
+ end
169
+
170
+ # AI Step Configs
171
+ class AgentConfig < Core::StepConfig
172
+ attribute :agent_id, DurableWorkflow::Types::Strict::String
173
+ attribute :prompt, DurableWorkflow::Types::Strict::String
174
+ attribute :output, DurableWorkflow::Types::Coercible::Symbol
175
+ end
176
+
177
+ class GuardrailConfig < Core::StepConfig
178
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
179
+ attribute? :input, DurableWorkflow::Types::Strict::String.optional
180
+ attribute :checks, DurableWorkflow::Types::Strict::Array.default([].freeze)
181
+ attribute? :on_fail, DurableWorkflow::Types::Strict::String.optional
182
+ end
183
+
184
+ class HandoffConfig < Core::StepConfig
185
+ attribute? :to, DurableWorkflow::Types::Strict::String.optional
186
+ attribute? :from, DurableWorkflow::Types::Strict::String.optional
187
+ attribute? :reason, DurableWorkflow::Types::Strict::String.optional
188
+ end
189
+
190
+ class FileSearchConfig < Core::StepConfig
191
+ attribute :query, DurableWorkflow::Types::Strict::String
192
+ attribute :files, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
193
+ attribute? :max_results, DurableWorkflow::Types::Strict::Integer.optional.default(10)
194
+ attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
195
+ end
196
+
197
+ class MCPConfig < Core::StepConfig
198
+ attribute :server, DurableWorkflow::Types::Strict::String
199
+ attribute :tool, DurableWorkflow::Types::Strict::String
200
+ attribute? :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
201
+ attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
202
+ end
203
+
204
+ # MCP Server configuration for consuming external MCP servers
205
+ class MCPServerConfig < BaseStruct
206
+ attribute? :url, DurableWorkflow::Types::Strict::String.optional
207
+ attribute? :headers, DurableWorkflow::Types::Hash.default({}.freeze)
208
+ attribute? :transport, DurableWorkflow::Types::Coercible::Symbol.default(:http)
209
+ attribute? :command, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).optional
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AI Extension loader
4
+ # Usage: require "durable_workflow/extensions/ai"
5
+
6
+ require_relative 'ai/ai'
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ # Base class for extensions
6
+ # Extensions inherit from this and call register! to set up
7
+ class Base
8
+ class << self
9
+ # Extension name (used as key in workflow.extensions)
10
+ def extension_name
11
+ @extension_name ||= (name ? name.split('::').last.downcase : 'anonymous')
12
+ end
13
+
14
+ attr_writer :extension_name
15
+
16
+ # Register all components of the extension
17
+ def register!
18
+ register_configs
19
+ register_executors
20
+ register_parser_hooks
21
+ end
22
+
23
+ # Override in subclass to register config classes
24
+ def register_configs
25
+ # Example:
26
+ # DurableWorkflow::Core.register_config("agent", AgentConfig)
27
+ end
28
+
29
+ # Override in subclass to register executors
30
+ def register_executors
31
+ # Example:
32
+ # DurableWorkflow::Core::Executors::Registry.register("agent", AgentExecutor)
33
+ end
34
+
35
+ # Override in subclass to register parser hooks
36
+ def register_parser_hooks
37
+ # Example:
38
+ # DurableWorkflow::Core::Parser.after_parse { |wf| ... }
39
+ end
40
+
41
+ # Helper to get extension data from workflow
42
+ def data_from(workflow)
43
+ workflow.extensions[extension_name.to_sym] || {}
44
+ end
45
+
46
+ # Helper to store extension data in workflow
47
+ def store_in(workflow, data)
48
+ workflow.with(extensions: workflow.extensions.merge(extension_name.to_sym => data))
49
+ end
50
+ end
51
+ end
52
+
53
+ # Registry of loaded extensions
54
+ @extensions = {}
55
+
56
+ class << self
57
+ attr_reader :extensions
58
+
59
+ def register(name, extension_class)
60
+ @extensions[name.to_sym] = extension_class
61
+ extension_class.register!
62
+ end
63
+
64
+ def [](name)
65
+ @extensions[name.to_sym]
66
+ end
67
+
68
+ def loaded?(name)
69
+ @extensions.key?(name.to_sym)
70
+ end
71
+
72
+ def reset!
73
+ @extensions = {}
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Runners
5
+ module Adapters
6
+ class Inline
7
+ def initialize(store: nil)
8
+ @store = store
9
+ end
10
+
11
+ def enqueue(workflow_id:, workflow_data:, execution_id:, action:, **kwargs)
12
+ # Execute immediately in current thread (for testing/dev)
13
+ perform(
14
+ workflow_id:,
15
+ workflow_data:,
16
+ execution_id:,
17
+ action:,
18
+ **kwargs
19
+ )
20
+ end
21
+
22
+ def perform(workflow_id:, workflow_data:, execution_id:, action:, input: {}, response: nil, approved: nil, **_)
23
+ workflow = DurableWorkflow.registry[workflow_id]
24
+ raise ExecutionError, "Workflow not found: #{workflow_id}" unless workflow
25
+
26
+ store = @store || DurableWorkflow.config&.store
27
+ raise ConfigError, 'No store configured' unless store
28
+
29
+ engine = Core::Engine.new(workflow, store:)
30
+
31
+ # Engine saves Execution with proper typed status - no manual status update needed
32
+ case action.to_sym
33
+ when :start
34
+ engine.run(input:, execution_id:)
35
+ when :resume
36
+ engine.resume(execution_id, response:, approved:)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Runners
5
+ module Adapters
6
+ class Sidekiq
7
+ def initialize(job_class: nil)
8
+ @job_class = job_class || default_job_class
9
+ end
10
+
11
+ def enqueue(workflow_id:, workflow_data:, execution_id:, action:, queue: nil, priority: nil, **kwargs)
12
+ job_args = {
13
+ workflow_id:,
14
+ workflow_data:,
15
+ execution_id:,
16
+ action: action.to_s,
17
+ **kwargs.compact
18
+ }
19
+
20
+ if queue
21
+ @job_class.set(queue:).perform_async(job_args)
22
+ else
23
+ @job_class.perform_async(job_args)
24
+ end
25
+
26
+ execution_id
27
+ end
28
+
29
+ private
30
+
31
+ def default_job_class
32
+ # Define a default job class if sidekiq is available
33
+ return @default_job_class if defined?(@default_job_class)
34
+
35
+ @default_job_class = Class.new do
36
+ if defined?(::Sidekiq::Job)
37
+ include ::Sidekiq::Job
38
+
39
+ def perform(args)
40
+ args = DurableWorkflow::Utils.deep_symbolize(args)
41
+
42
+ workflow = DurableWorkflow.registry[args[:workflow_id]]
43
+ raise DurableWorkflow::ExecutionError, "Workflow not found: #{args[:workflow_id]}" unless workflow
44
+
45
+ store = DurableWorkflow.config&.store
46
+ raise DurableWorkflow::ConfigError, 'No store configured' unless store
47
+
48
+ engine = DurableWorkflow::Core::Engine.new(workflow, store:)
49
+
50
+ # Engine saves Execution with proper typed status - no manual status update needed
51
+ case args[:action].to_sym
52
+ when :start
53
+ engine.run(input: args[:input], execution_id: args[:execution_id])
54
+ when :resume
55
+ engine.resume(args[:execution_id], response: args[:response], approved: args[:approved])
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Register in Object so it can be found by Sidekiq
62
+ Object.const_set(:DurableWorkflowJob, @default_job_class) unless defined?(::DurableWorkflowJob)
63
+
64
+ @default_job_class
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Runners
5
+ class Async
6
+ attr_reader :workflow, :store, :adapter
7
+
8
+ def initialize(workflow, store: nil, adapter: nil)
9
+ @workflow = workflow
10
+ @store = store || DurableWorkflow.config&.store
11
+ raise ConfigError, 'No store configured' unless @store
12
+
13
+ @adapter = adapter || Adapters::Inline.new(store: @store)
14
+ end
15
+
16
+ # Queue workflow for execution, return immediately
17
+ def run(input: {}, execution_id: nil, queue: nil, priority: nil)
18
+ exec_id = execution_id || SecureRandom.uuid
19
+
20
+ # Pre-create Execution with :pending status
21
+ execution = Core::Execution.new(
22
+ id: exec_id,
23
+ workflow_id: workflow.id,
24
+ status: :pending,
25
+ input: input.freeze,
26
+ ctx: {}
27
+ )
28
+ store.save(execution)
29
+
30
+ # Enqueue
31
+ adapter.enqueue(
32
+ workflow_id: workflow.id,
33
+ workflow_data: serialize_workflow,
34
+ execution_id: exec_id,
35
+ input:,
36
+ action: :start,
37
+ queue:,
38
+ priority:
39
+ )
40
+
41
+ exec_id
42
+ end
43
+
44
+ # Queue resume
45
+ def resume(execution_id, response: nil, approved: nil, queue: nil)
46
+ adapter.enqueue(
47
+ workflow_id: workflow.id,
48
+ workflow_data: serialize_workflow,
49
+ execution_id:,
50
+ response:,
51
+ approved:,
52
+ action: :resume,
53
+ queue:
54
+ )
55
+
56
+ execution_id
57
+ end
58
+
59
+ # Poll for completion
60
+ def wait(execution_id, timeout: 30, interval: 0.1)
61
+ deadline = Time.now + timeout
62
+
63
+ while Time.now < deadline
64
+ execution = store.load(execution_id)
65
+
66
+ case execution&.status
67
+ when :completed, :failed, :halted
68
+ return build_result(execution)
69
+ end
70
+
71
+ sleep(interval)
72
+ end
73
+
74
+ nil # Timeout
75
+ end
76
+
77
+ # Get current status
78
+ def status(execution_id)
79
+ execution = store.load(execution_id)
80
+ execution&.status || :unknown
81
+ end
82
+
83
+ private
84
+
85
+ def serialize_workflow
86
+ { id: workflow.id, name: workflow.name, version: workflow.version }
87
+ end
88
+
89
+ def build_result(execution)
90
+ Core::ExecutionResult.new(
91
+ status: execution.status,
92
+ execution_id: execution.id,
93
+ output: execution.result,
94
+ halt: execution.status == :halted ? Core::HaltResult.new(data: execution.halt_data || {}) : nil,
95
+ error: execution.error
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module DurableWorkflow
6
+ module Runners
7
+ # Stream event type
8
+ class Event < BaseStruct
9
+ attribute :type, Types::Strict::String
10
+ attribute :data, Types::Hash.default({}.freeze)
11
+ attribute :timestamp, Types::Any
12
+
13
+ def to_h
14
+ { type:, data:, timestamp: timestamp.is_a?(Time) ? timestamp.iso8601 : timestamp }
15
+ end
16
+
17
+ def to_json(*)
18
+ JSON.generate(to_h)
19
+ end
20
+
21
+ def to_sse
22
+ "event: #{type}\ndata: #{to_json}\n\n"
23
+ end
24
+ end
25
+
26
+ class Stream
27
+ EVENTS = %w[
28
+ workflow.started workflow.completed workflow.halted workflow.failed
29
+ step.started step.completed step.failed step.halted
30
+ ].freeze
31
+
32
+ attr_reader :workflow, :store, :subscribers
33
+
34
+ def initialize(workflow, store: nil)
35
+ @workflow = workflow
36
+ @store = store || DurableWorkflow.config&.store
37
+ raise ConfigError, 'No store configured' unless @store
38
+
39
+ @subscribers = []
40
+ end
41
+
42
+ # Subscribe to events
43
+ def subscribe(events: nil, &block)
44
+ @subscribers << { events:, handler: block }
45
+ self
46
+ end
47
+
48
+ # Run with event streaming
49
+ def run(input: {}, execution_id: nil)
50
+ emit('workflow.started', workflow_id: workflow.id, input:)
51
+
52
+ engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
53
+ result = engine.run(input:, execution_id:)
54
+
55
+ case result.status
56
+ when :completed
57
+ emit('workflow.completed', execution_id: result.execution_id, output: result.output)
58
+ when :halted
59
+ emit('workflow.halted', execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
60
+ when :failed
61
+ emit('workflow.failed', execution_id: result.execution_id, error: result.error)
62
+ end
63
+
64
+ result
65
+ end
66
+
67
+ # Resume with event streaming
68
+ def resume(execution_id, response: nil, approved: nil)
69
+ emit('workflow.resumed', execution_id:)
70
+
71
+ engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
72
+ result = engine.resume(execution_id, response:, approved:)
73
+
74
+ case result.status
75
+ when :completed
76
+ emit('workflow.completed', execution_id: result.execution_id, output: result.output)
77
+ when :halted
78
+ emit('workflow.halted', execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
79
+ when :failed
80
+ emit('workflow.failed', execution_id: result.execution_id, error: result.error)
81
+ end
82
+
83
+ result
84
+ end
85
+
86
+ # Emit event
87
+ def emit(type, **data)
88
+ event = Event.new(type:, data:, timestamp: Time.now)
89
+
90
+ subscribers.each do |sub|
91
+ next if sub[:events] && !sub[:events].include?(type)
92
+
93
+ sub[:handler].call(event)
94
+ end
95
+ end
96
+ end
97
+
98
+ # Engine subclass with event hooks
99
+ class StreamingEngine < Core::Engine
100
+ def initialize(workflow, store:, emitter:)
101
+ super(workflow, store:)
102
+ @emitter = emitter
103
+ end
104
+
105
+ private
106
+
107
+ def execute_step(state, step)
108
+ @emitter.call('step.started', step_id: step.id, step_type: step.type)
109
+
110
+ outcome = super
111
+
112
+ event = case outcome.result
113
+ when Core::HaltResult then 'step.halted'
114
+ else 'step.completed'
115
+ end
116
+
117
+ @emitter.call(event, step_id: step.id, output: outcome.result.output)
118
+
119
+ outcome
120
+ rescue StandardError => e
121
+ @emitter.call('step.failed', step_id: step.id, error: e.message)
122
+ raise
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Runners
5
+ class Sync
6
+ attr_reader :workflow, :store
7
+
8
+ def initialize(workflow, store: nil)
9
+ @workflow = workflow
10
+ @store = store || DurableWorkflow.config&.store
11
+ raise ConfigError, 'No store configured' unless @store
12
+ end
13
+
14
+ # Run workflow, block until complete/halted
15
+ def run(input: {}, execution_id: nil)
16
+ engine = Core::Engine.new(workflow, store:)
17
+ engine.run(input:, execution_id:)
18
+ end
19
+
20
+ # Resume halted workflow
21
+ def resume(execution_id, response: nil, approved: nil)
22
+ engine = Core::Engine.new(workflow, store:)
23
+ engine.resume(execution_id, response:, approved:)
24
+ end
25
+
26
+ # Run until fully complete (auto-handle halts with block)
27
+ # Without block, returns halted result when halt encountered
28
+ def run_until_complete(input: {}, execution_id: nil)
29
+ result = run(input:, execution_id:)
30
+
31
+ while result.halted? && block_given?
32
+ response = yield result.halt
33
+ result = resume(result.execution_id, response:)
34
+ end
35
+
36
+ result
37
+ end
38
+ end
39
+ end
40
+ end