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,286 @@
1
+ id: support_agent
2
+ name: Customer Support Agent
3
+ version: "1.0"
4
+ description: AI-powered customer support with specialized agents
5
+
6
+ inputs:
7
+ message:
8
+ type: string
9
+ required: true
10
+ description: Customer's support request
11
+ customer_id:
12
+ type: string
13
+ required: false
14
+ description: Optional customer identifier
15
+ context:
16
+ type: object
17
+ required: false
18
+ description: Additional context (order_id, etc.)
19
+
20
+ # AI Extension Configuration
21
+ agents:
22
+ - id: triage
23
+ name: Triage Agent
24
+ model: gpt-4o-mini
25
+ instructions: |
26
+ You are a support triage agent. Analyze the customer's request and determine:
27
+ 1. The category (billing, technical, general)
28
+ 2. The urgency (low, medium, high)
29
+ 3. Key entities mentioned (order IDs, product names, etc.)
30
+
31
+ Always be polite and acknowledge the customer's concern.
32
+ Use the classify_request tool to record your analysis.
33
+ tools:
34
+ - classify_request
35
+
36
+ - id: billing
37
+ name: Billing Agent
38
+ model: gpt-4o
39
+ instructions: |
40
+ You are a billing specialist. Help customers with:
41
+ - Order lookups and status
42
+ - Refunds and cancellations
43
+ - Payment issues
44
+ - Invoice requests
45
+
46
+ Always verify the customer's identity before processing sensitive requests.
47
+ Use available tools to look up information and take actions.
48
+ tools:
49
+ - lookup_order
50
+ - refund_order
51
+ - create_ticket
52
+ - escalate
53
+ handoffs:
54
+ - agent_id: technical
55
+ description: Transfer to technical support for product issues
56
+
57
+ - id: technical
58
+ name: Technical Support Agent
59
+ model: gpt-4o
60
+ instructions: |
61
+ You are a technical support specialist. Help customers with:
62
+ - Product setup and configuration
63
+ - Troubleshooting issues
64
+ - Password resets
65
+ - Account access problems
66
+
67
+ Be patient and provide step-by-step guidance.
68
+ tools:
69
+ - reset_password
70
+ - check_status
71
+ - create_ticket
72
+ - escalate
73
+ handoffs:
74
+ - agent_id: billing
75
+ description: Transfer to billing for payment issues
76
+
77
+ tools:
78
+ - id: classify_request
79
+ description: Classify the customer's support request
80
+ parameters:
81
+ - name: category
82
+ type: string
83
+ required: true
84
+ description: "Category: billing, technical, or general"
85
+ - name: urgency
86
+ type: string
87
+ required: true
88
+ description: "Urgency: low, medium, or high"
89
+ - name: summary
90
+ type: string
91
+ required: true
92
+ description: Brief summary of the request
93
+ service: SupportServices
94
+ method: classify_request
95
+
96
+ - id: lookup_order
97
+ description: Look up an order by ID or customer email
98
+ parameters:
99
+ - name: order_id
100
+ type: string
101
+ description: Order ID to look up
102
+ - name: email
103
+ type: string
104
+ description: Customer email to find orders
105
+ service: SupportServices
106
+ method: lookup_order
107
+
108
+ - id: refund_order
109
+ description: Process a refund for an order
110
+ parameters:
111
+ - name: order_id
112
+ type: string
113
+ required: true
114
+ description: Order ID to refund
115
+ - name: reason
116
+ type: string
117
+ required: true
118
+ description: Reason for refund
119
+ - name: amount
120
+ type: number
121
+ description: Partial refund amount (omit for full refund)
122
+ service: SupportServices
123
+ method: refund_order
124
+
125
+ - id: create_ticket
126
+ description: Create a support ticket for follow-up
127
+ parameters:
128
+ - name: subject
129
+ type: string
130
+ required: true
131
+ description: Ticket subject
132
+ - name: description
133
+ type: string
134
+ required: true
135
+ description: Detailed description
136
+ - name: priority
137
+ type: string
138
+ description: "Priority: low, medium, high"
139
+ service: SupportServices
140
+ method: create_ticket
141
+
142
+ - id: check_status
143
+ description: Check the status of a support ticket
144
+ parameters:
145
+ - name: ticket_id
146
+ type: string
147
+ required: true
148
+ description: Ticket ID to check
149
+ service: SupportServices
150
+ method: check_status
151
+
152
+ - id: reset_password
153
+ description: Send password reset email to customer
154
+ parameters:
155
+ - name: email
156
+ type: string
157
+ required: true
158
+ description: Customer's email address
159
+ service: SupportServices
160
+ method: reset_password
161
+
162
+ - id: escalate
163
+ description: Escalate to a human support agent
164
+ parameters:
165
+ - name: reason
166
+ type: string
167
+ required: true
168
+ description: Reason for escalation
169
+ - name: urgency
170
+ type: string
171
+ required: true
172
+ description: "Urgency level: medium or high"
173
+ service: SupportServices
174
+ method: escalate
175
+
176
+ # Workflow Steps
177
+ steps:
178
+ - id: start
179
+ type: start
180
+ next: moderate_input
181
+
182
+ - id: moderate_input
183
+ type: guardrail
184
+ content: "$input.message"
185
+ checks:
186
+ - type: moderation
187
+ - type: prompt_injection
188
+ on_fail: rejected
189
+ next: triage
190
+
191
+ - id: triage
192
+ type: agent
193
+ agent_id: triage
194
+ prompt: |
195
+ Customer message: $input.message
196
+ Customer ID: $input.customer_id
197
+ Context: $input.context
198
+
199
+ Please analyze this request and classify it.
200
+ output: triage_result
201
+ next: route_request
202
+
203
+ - id: route_request
204
+ type: router
205
+ routes:
206
+ - when:
207
+ field: triage_result.category
208
+ op: eq
209
+ value: "billing"
210
+ then: billing_agent
211
+ - when:
212
+ field: triage_result.category
213
+ op: eq
214
+ value: "technical"
215
+ then: technical_agent
216
+ default: general_response
217
+
218
+ - id: billing_agent
219
+ type: agent
220
+ agent_id: billing
221
+ prompt: |
222
+ Customer request (classified as billing, urgency: $triage_result.urgency):
223
+ $input.message
224
+
225
+ Customer ID: $input.customer_id
226
+ Context: $input.context
227
+
228
+ Please help resolve this billing issue.
229
+ output: agent_response
230
+ next: check_handoff
231
+
232
+ - id: technical_agent
233
+ type: agent
234
+ agent_id: technical
235
+ prompt: |
236
+ Customer request (classified as technical, urgency: $triage_result.urgency):
237
+ $input.message
238
+
239
+ Customer ID: $input.customer_id
240
+
241
+ Please help resolve this technical issue.
242
+ output: agent_response
243
+ next: check_handoff
244
+
245
+ - id: general_response
246
+ type: agent
247
+ agent_id: triage
248
+ prompt: |
249
+ The customer's request doesn't fit billing or technical categories.
250
+ Please provide a helpful general response or ask clarifying questions.
251
+
252
+ Customer message: $input.message
253
+ output: agent_response
254
+ next: end
255
+
256
+ - id: check_handoff
257
+ type: router
258
+ routes:
259
+ - when:
260
+ field: _handoff_to
261
+ op: exists
262
+ then: handle_handoff
263
+ default: end
264
+
265
+ - id: handle_handoff
266
+ type: handoff
267
+ target_agent: "$_handoff_to"
268
+ context:
269
+ original_message: "$input.message"
270
+ previous_response: "$agent_response"
271
+ triage: "$triage_result"
272
+ output: agent_response
273
+ next: end
274
+
275
+ - id: rejected
276
+ type: assign
277
+ set:
278
+ agent_response: "I apologize, but I cannot process this request. Please contact support directly."
279
+ triage_result: null
280
+ next: end
281
+
282
+ - id: end
283
+ type: end
284
+ result:
285
+ response: "$agent_response"
286
+ triage: "$triage_result"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Core
5
+ # Stateless condition evaluator
6
+ class ConditionEvaluator
7
+ OPS = {
8
+ 'eq' => ->(v, e) { v == e },
9
+ 'neq' => ->(v, e) { v != e },
10
+ 'gt' => ->(v, e) { v.to_f > e.to_f },
11
+ 'lt' => ->(v, e) { v.to_f < e.to_f },
12
+ 'gte' => ->(v, e) { v.to_f >= e.to_f },
13
+ 'lte' => ->(v, e) { v.to_f <= e.to_f },
14
+ 'in' => ->(v, e) { Array(e).include?(v) },
15
+ 'not_in' => ->(v, e) { !Array(e).include?(v) },
16
+ 'contains' => ->(v, e) { v.to_s.include?(e.to_s) },
17
+ 'starts_with' => ->(v, e) { v.to_s.start_with?(e.to_s) },
18
+ 'ends_with' => ->(v, e) { v.to_s.end_with?(e.to_s) },
19
+ 'matches' => ->(v, e) { v.to_s.match?(Regexp.new(e.to_s)) },
20
+ 'exists' => ->(v, _) { !v.nil? },
21
+ 'empty' => ->(v, _) { v.nil? || (v.respond_to?(:empty?) && v.empty?) },
22
+ 'truthy' => ->(v, _) { !v.nil? },
23
+ 'falsy' => ->(v, _) { !v }
24
+ }.freeze
25
+
26
+ class << self
27
+ # Evaluate Route or Condition
28
+ def match?(state, cond)
29
+ val = Resolver.resolve(state, "$#{cond.field}")
30
+ exp = Resolver.resolve(state, cond.value)
31
+ op = OPS.fetch(cond.op) { ->(_, _) { false } }
32
+ op.call(val, exp)
33
+ rescue StandardError => e
34
+ DurableWorkflow.log(:warn, "Condition failed: #{e.message}", field: cond.field, op: cond.op)
35
+ false
36
+ end
37
+
38
+ # Find first matching route
39
+ def find_route(state, routes)
40
+ routes.find { match?(state, _1) }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module DurableWorkflow
6
+ module Core
7
+ class Engine
8
+ FINISHED = '__FINISHED__'
9
+
10
+ attr_reader :workflow, :store
11
+
12
+ def initialize(workflow, store: nil)
13
+ @workflow = workflow
14
+ @store = store || DurableWorkflow.config&.store
15
+ raise ConfigError, 'No store configured. Use Redis, ActiveRecord, or Sequel.' unless @store
16
+ end
17
+
18
+ def run(input: {}, execution_id: nil)
19
+ exec_id = execution_id || SecureRandom.uuid
20
+
21
+ state = State.new(
22
+ execution_id: exec_id,
23
+ workflow_id: workflow.id,
24
+ input: DurableWorkflow::Utils.deep_symbolize(input)
25
+ )
26
+
27
+ # Save initial Execution with :running status
28
+ save_execution(state, ExecutionResult.new(status: :running, execution_id: exec_id))
29
+
30
+ if workflow.timeout
31
+ Timeout.timeout(workflow.timeout) do
32
+ execute_from(state, workflow.first_step.id)
33
+ end
34
+ else
35
+ execute_from(state, workflow.first_step.id)
36
+ end
37
+ rescue Timeout::Error
38
+ result = ExecutionResult.new(status: :failed, execution_id: state.execution_id, error: "Workflow timeout after #{workflow.timeout}s")
39
+ save_execution(state, result)
40
+ result
41
+ end
42
+
43
+ def resume(execution_id, response: nil, approved: nil)
44
+ execution = @store.load(execution_id)
45
+ raise ExecutionError, "Execution not found: #{execution_id}" unless execution
46
+
47
+ state = execution.to_state
48
+ state = state.with_ctx(response:) if response
49
+ state = state.with_ctx(approved:) unless approved.nil?
50
+
51
+ # Use recover_to from Execution, or fall back to current_step
52
+ resume_step = execution.recover_to || execution.current_step
53
+
54
+ execute_from(state, resume_step)
55
+ end
56
+
57
+ private
58
+
59
+ def execute_from(state, step_id)
60
+ while step_id && step_id != FINISHED
61
+ state = state.with_current_step(step_id)
62
+
63
+ # Save intermediate state as :running
64
+ save_execution(state, ExecutionResult.new(status: :running, execution_id: state.execution_id))
65
+
66
+ step = workflow.find_step(step_id)
67
+ raise ExecutionError, "Step not found: #{step_id}" unless step
68
+
69
+ outcome = execute_step(state, step)
70
+ state = outcome.state
71
+
72
+ case outcome.result
73
+ when HaltResult
74
+ return handle_halt(state, outcome.result)
75
+ when ContinueResult
76
+ step_id = outcome.result.next_step
77
+ else
78
+ raise ExecutionError, "Unknown result: #{outcome.result.class}"
79
+ end
80
+ end
81
+
82
+ # Completed
83
+ result = ExecutionResult.new(status: :completed, execution_id: state.execution_id, output: state.ctx[:result])
84
+ save_execution(state, result)
85
+ result
86
+ end
87
+
88
+ def execute_step(state, step)
89
+ executor_class = Executors::Registry[step.type]
90
+ raise ExecutionError, "No executor for: #{step.type}" unless executor_class
91
+
92
+ start = Time.now
93
+ outcome = executor_class.new(step).call(state)
94
+ duration = ((Time.now - start) * 1000).to_i
95
+
96
+ @store.record(Entry.new(
97
+ id: SecureRandom.uuid,
98
+ execution_id: state.execution_id,
99
+ step_id: step.id,
100
+ step_type: step.type,
101
+ action: outcome.result.is_a?(HaltResult) ? :halted : :completed,
102
+ duration_ms: duration,
103
+ output: outcome.result.output,
104
+ timestamp: Time.now
105
+ ))
106
+
107
+ outcome
108
+ rescue StandardError => e
109
+ @store.record(Entry.new(
110
+ id: SecureRandom.uuid,
111
+ execution_id: state.execution_id,
112
+ step_id: step.id,
113
+ step_type: step.type,
114
+ action: :failed,
115
+ error: "#{e.class}: #{e.message}",
116
+ timestamp: Time.now
117
+ ))
118
+
119
+ if step.on_error
120
+ # Store error info in ctx for access by error handler step
121
+ error_state = state.with_ctx(_last_error: { step: step.id, message: e.message, class: e.class.name })
122
+ return StepOutcome.new(state: error_state, result: ContinueResult.new(next_step: step.on_error))
123
+ end
124
+
125
+ raise
126
+ end
127
+
128
+ def handle_halt(state, halt_result)
129
+ result = ExecutionResult.new(
130
+ status: :halted,
131
+ execution_id: state.execution_id,
132
+ output: state.ctx[:result],
133
+ halt: halt_result
134
+ )
135
+ save_execution(state, result)
136
+ result
137
+ end
138
+
139
+ def save_execution(state, result)
140
+ execution = Execution.from_state(state, result)
141
+ @store.save(execution)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Core
5
+ module Executors
6
+ class Approval < Base
7
+ Registry.register('approval', self)
8
+
9
+ def call(state)
10
+ # Check if timed out (when resuming)
11
+ requested_at_str = state.ctx.dig(:_halt, :requested_at)
12
+ if requested_at_str && config.timeout
13
+ requested_at = Time.parse(requested_at_str)
14
+ if Time.now - requested_at > config.timeout
15
+ return continue(state, next_step: config.on_timeout) if config.on_timeout
16
+
17
+ raise ExecutionError, 'Approval timeout'
18
+
19
+ end
20
+ end
21
+
22
+ # Resuming from approval
23
+ if state.ctx.key?(:approved)
24
+ approved = state.ctx[:approved]
25
+ state = state.with(ctx: state.ctx.except(:approved))
26
+ if approved
27
+ return continue(state)
28
+ elsif config.on_reject
29
+ return continue(state, next_step: config.on_reject)
30
+ else
31
+ raise ExecutionError, 'Rejected'
32
+ end
33
+ end
34
+
35
+ # Request approval
36
+ halt(state,
37
+ data: {
38
+ type: :approval,
39
+ prompt: resolve(state, config.prompt),
40
+ context: resolve(state, config.context),
41
+ approvers: config.approvers,
42
+ timeout: config.timeout,
43
+ requested_at: Time.now.iso8601
44
+ },
45
+ resume_step: step.id,
46
+ prompt: resolve(state, config.prompt))
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Core
5
+ module Executors
6
+ class Assign < Base
7
+ Registry.register('assign', self)
8
+
9
+ def call(state)
10
+ state = config.set.reduce(state) do |s, (k, v)|
11
+ store(s, k, resolve(s, v))
12
+ end
13
+ continue(state)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module DurableWorkflow
6
+ module Core
7
+ module Executors
8
+ class Base
9
+ attr_reader :step, :config
10
+
11
+ def initialize(step)
12
+ @step = step
13
+ @config = step.config
14
+ end
15
+
16
+ # Executors receive state and return StepOutcome
17
+ def call(state)
18
+ raise NotImplementedError
19
+ end
20
+
21
+ private
22
+
23
+ def next_step
24
+ step.next_step
25
+ end
26
+
27
+ # Pure resolve - takes state explicitly
28
+ def resolve(state, v)
29
+ Resolver.resolve(state, v)
30
+ end
31
+
32
+ # Return StepOutcome with continue result
33
+ def continue(state, next_step: nil, output: nil)
34
+ StepOutcome.new(
35
+ state:,
36
+ result: ContinueResult.new(next_step: next_step || self.next_step, output:)
37
+ )
38
+ end
39
+
40
+ # Return StepOutcome with halt result
41
+ def halt(state, data: {}, resume_step: nil, prompt: nil)
42
+ StepOutcome.new(
43
+ state:,
44
+ result: HaltResult.new(data:, resume_step: resume_step || next_step, prompt:)
45
+ )
46
+ end
47
+
48
+ # Immutable store - returns new state
49
+ def store(state, key, val)
50
+ return state unless key
51
+
52
+ state.with_ctx(key.to_sym => DurableWorkflow::Utils.deep_symbolize(val))
53
+ end
54
+
55
+ def with_timeout(seconds = nil, &)
56
+ timeout = seconds || config_timeout
57
+ return yield unless timeout
58
+
59
+ Timeout.timeout(timeout, &)
60
+ rescue Timeout::Error
61
+ raise ExecutionError, "Step '#{step.id}' timed out after #{timeout}s"
62
+ end
63
+
64
+ def with_retry(max_retries: 0, delay: 1.0, backoff: 2.0)
65
+ attempts = 0
66
+ begin
67
+ attempts += 1
68
+ yield
69
+ rescue StandardError => e
70
+ if attempts <= max_retries
71
+ sleep_time = delay * (backoff**(attempts - 1))
72
+ log(:warn, "Retry #{attempts}/#{max_retries} after #{sleep_time}s", error: e.message)
73
+ sleep(sleep_time)
74
+ retry
75
+ end
76
+ raise
77
+ end
78
+ end
79
+
80
+ def config_timeout
81
+ config.respond_to?(:timeout) ? config.timeout : nil
82
+ end
83
+
84
+ def log(level, msg, **data)
85
+ DurableWorkflow.log(level, msg, step_id: step.id, step_type: step.type, **data)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Core
5
+ module Executors
6
+ class Call < Base
7
+ Registry.register('call', self)
8
+
9
+ def call(state)
10
+ svc = resolve_service(config.service)
11
+ method = config.method_name
12
+ input = resolve(state, config.input)
13
+
14
+ result = with_retry(
15
+ max_retries: config.retries,
16
+ delay: config.retry_delay,
17
+ backoff: config.retry_backoff
18
+ ) do
19
+ with_timeout { invoke(svc, method, input) }
20
+ end
21
+
22
+ # Runtime schema validation (if schema defined)
23
+ validate_output!(result) if output_schema
24
+
25
+ state = store(state, output_key, result)
26
+ continue(state, output: result)
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_service(name)
32
+ DurableWorkflow.config&.service_resolver&.call(name) || Object.const_get(name)
33
+ end
34
+
35
+ def invoke(svc, method, input)
36
+ target = svc.respond_to?(method) ? svc : svc.new
37
+ m = target.method(method)
38
+
39
+ # Check if method takes keyword args
40
+ has_kwargs = m.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
41
+
42
+ if has_kwargs && input.is_a?(Hash)
43
+ m.call(**input.transform_keys(&:to_sym))
44
+ elsif m.arity.zero?
45
+ m.call
46
+ else
47
+ m.call(input)
48
+ end
49
+ end
50
+
51
+ def output_key
52
+ case config.output
53
+ when Symbol, String then config.output
54
+ when OutputConfig then config.output.key
55
+ when Hash then config.output[:key]
56
+ end
57
+ end
58
+
59
+ def output_schema
60
+ case config.output
61
+ when OutputConfig then config.output.schema
62
+ when Hash then config.output[:schema]
63
+ end
64
+ end
65
+
66
+ def validate_output!(result)
67
+ SchemaValidator.validate!(
68
+ result,
69
+ output_schema,
70
+ context: "Step '#{step.id}' output"
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end