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.
- checksums.yaml +7 -0
- data/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- metadata +275 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
# 04-STEPS: Built-in Step Executors
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement all core step executors: start, end, assign, call, router, loop, parallel, transform, halt, approval, workflow (sub-workflow).
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
- 01-GEMSPEC completed
|
|
10
|
+
- 02-TYPES completed
|
|
11
|
+
- 03-EXECUTION completed
|
|
12
|
+
|
|
13
|
+
## Files to Create
|
|
14
|
+
|
|
15
|
+
### 1. `lib/durable_workflow/core/executors/start.rb`
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# frozen_string_literal: true
|
|
19
|
+
|
|
20
|
+
module DurableWorkflow
|
|
21
|
+
module Core
|
|
22
|
+
module Executors
|
|
23
|
+
class Start < Base
|
|
24
|
+
Registry.register("start", self)
|
|
25
|
+
|
|
26
|
+
def call(state)
|
|
27
|
+
validate_inputs!(state)
|
|
28
|
+
state = apply_defaults(state)
|
|
29
|
+
state = store(state, :input, state.input)
|
|
30
|
+
continue(state)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def workflow_inputs(state)
|
|
36
|
+
DurableWorkflow.registry[state.workflow_id]&.inputs || []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_inputs!(state)
|
|
40
|
+
workflow_inputs(state).each do |input_def|
|
|
41
|
+
value = state.input[input_def.name.to_sym]
|
|
42
|
+
|
|
43
|
+
if input_def.required && value.nil?
|
|
44
|
+
raise ValidationError, "Missing required input: #{input_def.name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
next if value.nil?
|
|
48
|
+
validate_type!(input_def.name, value, input_def.type)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_type!(name, value, type)
|
|
53
|
+
valid = case type
|
|
54
|
+
when "string" then value.is_a?(String)
|
|
55
|
+
when "integer" then value.is_a?(Integer)
|
|
56
|
+
when "number" then value.is_a?(Numeric)
|
|
57
|
+
when "boolean" then value == true || value == false
|
|
58
|
+
when "array" then value.is_a?(Array)
|
|
59
|
+
when "object" then value.is_a?(Hash)
|
|
60
|
+
else true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise ValidationError, "Input '#{name}' must be #{type}, got #{value.class}" unless valid
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def apply_defaults(state)
|
|
67
|
+
updates = {}
|
|
68
|
+
workflow_inputs(state).each do |input_def|
|
|
69
|
+
key = input_def.name.to_sym
|
|
70
|
+
if state.input[key].nil? && !input_def.default.nil?
|
|
71
|
+
updates[key] = input_def.default
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
return state if updates.empty?
|
|
75
|
+
state.with(input: state.input.merge(updates))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 2. `lib/durable_workflow/core/executors/end.rb`
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# frozen_string_literal: true
|
|
87
|
+
|
|
88
|
+
module DurableWorkflow
|
|
89
|
+
module Core
|
|
90
|
+
module Executors
|
|
91
|
+
class End < Base
|
|
92
|
+
FINISHED = "__FINISHED__"
|
|
93
|
+
Registry.register("end", self)
|
|
94
|
+
|
|
95
|
+
def call(state)
|
|
96
|
+
raw = config.result || state.ctx.dup
|
|
97
|
+
result = resolve(state, raw)
|
|
98
|
+
state = store(state, :result, result)
|
|
99
|
+
continue(state, next_step: FINISHED, output: result)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 3. `lib/durable_workflow/core/executors/assign.rb`
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# frozen_string_literal: true
|
|
111
|
+
|
|
112
|
+
module DurableWorkflow
|
|
113
|
+
module Core
|
|
114
|
+
module Executors
|
|
115
|
+
class Assign < Base
|
|
116
|
+
Registry.register("assign", self)
|
|
117
|
+
|
|
118
|
+
def call(state)
|
|
119
|
+
state = config.set.reduce(state) do |s, (k, v)|
|
|
120
|
+
store(s, k, resolve(s, v))
|
|
121
|
+
end
|
|
122
|
+
continue(state)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4. `lib/durable_workflow/core/executors/call.rb`
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
# frozen_string_literal: true
|
|
134
|
+
|
|
135
|
+
module DurableWorkflow
|
|
136
|
+
module Core
|
|
137
|
+
module Executors
|
|
138
|
+
class Call < Base
|
|
139
|
+
Registry.register("call", self)
|
|
140
|
+
|
|
141
|
+
def call(state)
|
|
142
|
+
svc = resolve_service(config.service)
|
|
143
|
+
method = config.method_name
|
|
144
|
+
input = resolve(state, config.input)
|
|
145
|
+
|
|
146
|
+
result = with_retry(
|
|
147
|
+
max_retries: config.retries,
|
|
148
|
+
delay: config.retry_delay,
|
|
149
|
+
backoff: config.retry_backoff
|
|
150
|
+
) do
|
|
151
|
+
with_timeout { invoke(svc, method, input) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
state = store(state, config.output, result)
|
|
155
|
+
continue(state, output: result)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def resolve_service(name)
|
|
161
|
+
DurableWorkflow.config&.service_resolver&.call(name) || Object.const_get(name)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def invoke(svc, method, input)
|
|
165
|
+
target = svc.respond_to?(method) ? svc : svc.new
|
|
166
|
+
m = target.method(method)
|
|
167
|
+
|
|
168
|
+
# Check if method takes keyword args
|
|
169
|
+
has_kwargs = m.parameters.any? { |type, _| type == :key || type == :keyreq || type == :keyrest }
|
|
170
|
+
|
|
171
|
+
if has_kwargs && input.is_a?(Hash)
|
|
172
|
+
m.call(**input.transform_keys(&:to_sym))
|
|
173
|
+
elsif m.arity == 0
|
|
174
|
+
m.call
|
|
175
|
+
else
|
|
176
|
+
m.call(input)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 5. `lib/durable_workflow/core/executors/router.rb`
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# frozen_string_literal: true
|
|
189
|
+
|
|
190
|
+
module DurableWorkflow
|
|
191
|
+
module Core
|
|
192
|
+
module Executors
|
|
193
|
+
class Router < Base
|
|
194
|
+
Registry.register("router", self)
|
|
195
|
+
|
|
196
|
+
def call(state)
|
|
197
|
+
routes = config.routes
|
|
198
|
+
default = config.default
|
|
199
|
+
|
|
200
|
+
route = ConditionEvaluator.find_route(state, routes)
|
|
201
|
+
|
|
202
|
+
if route
|
|
203
|
+
continue(state, next_step: route.target)
|
|
204
|
+
elsif default
|
|
205
|
+
continue(state, next_step: default)
|
|
206
|
+
else
|
|
207
|
+
raise ExecutionError, "No matching route and no default for '#{step.id}'"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 6. `lib/durable_workflow/core/executors/loop.rb`
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# frozen_string_literal: true
|
|
220
|
+
|
|
221
|
+
module DurableWorkflow
|
|
222
|
+
module Core
|
|
223
|
+
module Executors
|
|
224
|
+
class Loop < Base
|
|
225
|
+
Registry.register("loop", self)
|
|
226
|
+
MAX_ITER = 100
|
|
227
|
+
|
|
228
|
+
def call(state)
|
|
229
|
+
config.over ? foreach_loop(state) : while_loop(state)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def foreach_loop(state)
|
|
235
|
+
collection = resolve(state, config.over)
|
|
236
|
+
raise ExecutionError, "Loop 'over' must be array" unless collection.is_a?(Array)
|
|
237
|
+
|
|
238
|
+
item_key = config.as
|
|
239
|
+
index_key = config.index_as
|
|
240
|
+
max = config.max
|
|
241
|
+
raise ExecutionError, "Collection exceeds max (#{max})" if collection.size > max
|
|
242
|
+
|
|
243
|
+
results = []
|
|
244
|
+
collection.each_with_index do |item, i|
|
|
245
|
+
state = store(state, item_key, item)
|
|
246
|
+
state = store(state, index_key, i)
|
|
247
|
+
outcome = execute_body(state)
|
|
248
|
+
|
|
249
|
+
# Bubble up halts
|
|
250
|
+
return outcome if outcome.result.is_a?(HaltResult)
|
|
251
|
+
|
|
252
|
+
state = outcome.state
|
|
253
|
+
results << outcome.result.output
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
state = cleanup(state, item_key, index_key)
|
|
257
|
+
state = store(state, config.output, results)
|
|
258
|
+
continue(state)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def while_loop(state)
|
|
262
|
+
cond = config.while
|
|
263
|
+
max = config.max
|
|
264
|
+
results = []
|
|
265
|
+
i = 0
|
|
266
|
+
|
|
267
|
+
while ConditionEvaluator.match?(state, cond)
|
|
268
|
+
i += 1
|
|
269
|
+
if i > max
|
|
270
|
+
return config.on_exhausted ? continue(state, next_step: config.on_exhausted) : raise(ExecutionError, "Loop exceeded max")
|
|
271
|
+
end
|
|
272
|
+
state = store(state, :iteration, i)
|
|
273
|
+
outcome = execute_body(state)
|
|
274
|
+
|
|
275
|
+
# Bubble up halts
|
|
276
|
+
return outcome if outcome.result.is_a?(HaltResult)
|
|
277
|
+
|
|
278
|
+
state = outcome.state
|
|
279
|
+
results << outcome.result.output
|
|
280
|
+
break if state.ctx[:break_loop]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
state = cleanup(state, :iteration, :break_loop)
|
|
284
|
+
state = store(state, config.output, results)
|
|
285
|
+
continue(state)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def execute_body(state)
|
|
289
|
+
body = config.do
|
|
290
|
+
result = nil
|
|
291
|
+
|
|
292
|
+
body.each do |step_def|
|
|
293
|
+
executor = Registry[step_def.type]
|
|
294
|
+
raise ExecutionError, "Unknown step type: #{step_def.type}" unless executor
|
|
295
|
+
|
|
296
|
+
start_time = Time.now
|
|
297
|
+
outcome = executor.new(step_def).call(state)
|
|
298
|
+
duration = ((Time.now - start_time) * 1000).to_i
|
|
299
|
+
|
|
300
|
+
record_nested_entry(state, step_def, outcome, duration)
|
|
301
|
+
|
|
302
|
+
# Bubble up halts
|
|
303
|
+
return outcome if outcome.result.is_a?(HaltResult)
|
|
304
|
+
|
|
305
|
+
state = outcome.state
|
|
306
|
+
result = outcome.result
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
StepOutcome.new(state:, result: result || ContinueResult.new)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def record_nested_entry(state, step_def, outcome, duration)
|
|
313
|
+
wf_store = DurableWorkflow.config&.store
|
|
314
|
+
return unless wf_store
|
|
315
|
+
|
|
316
|
+
wf_store.record(Entry.new(
|
|
317
|
+
id: SecureRandom.uuid,
|
|
318
|
+
execution_id: state.execution_id,
|
|
319
|
+
step_id: "#{step.id}:#{step_def.id}",
|
|
320
|
+
step_type: step_def.type,
|
|
321
|
+
action: outcome.result.is_a?(HaltResult) ? :halted : :completed,
|
|
322
|
+
duration_ms: duration,
|
|
323
|
+
output: outcome.result.output,
|
|
324
|
+
timestamp: Time.now
|
|
325
|
+
))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def cleanup(state, *keys)
|
|
329
|
+
new_ctx = state.ctx.except(*keys)
|
|
330
|
+
state.with(ctx: new_ctx)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### 7. `lib/durable_workflow/core/executors/parallel.rb`
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# frozen_string_literal: true
|
|
342
|
+
|
|
343
|
+
require "async"
|
|
344
|
+
require "async/barrier"
|
|
345
|
+
|
|
346
|
+
module DurableWorkflow
|
|
347
|
+
module Core
|
|
348
|
+
module Executors
|
|
349
|
+
class Parallel < Base
|
|
350
|
+
Registry.register("parallel", self)
|
|
351
|
+
|
|
352
|
+
def call(state)
|
|
353
|
+
branches = config.branches
|
|
354
|
+
return continue(state) if branches.empty?
|
|
355
|
+
|
|
356
|
+
wait_mode = config.wait || "all"
|
|
357
|
+
required = case wait_mode
|
|
358
|
+
when "all" then branches.size
|
|
359
|
+
when "any" then 1
|
|
360
|
+
when Integer then [wait_mode, branches.size].min
|
|
361
|
+
else branches.size
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
outcomes = Array.new(branches.size)
|
|
365
|
+
errors = []
|
|
366
|
+
|
|
367
|
+
Sync do
|
|
368
|
+
barrier = Async::Barrier.new
|
|
369
|
+
|
|
370
|
+
begin
|
|
371
|
+
branches.each_with_index do |branch, i|
|
|
372
|
+
barrier.async do
|
|
373
|
+
executor = Registry[branch.type]
|
|
374
|
+
raise ExecutionError, "Unknown branch type: #{branch.type}" unless executor
|
|
375
|
+
outcomes[i] = executor.new(branch).call(state)
|
|
376
|
+
rescue => e
|
|
377
|
+
errors << { branch: branch.id, error: e.message }
|
|
378
|
+
outcomes[i] = nil
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if wait_mode == "any"
|
|
383
|
+
barrier.wait { break if outcomes.compact.size >= required }
|
|
384
|
+
else
|
|
385
|
+
barrier.wait
|
|
386
|
+
end
|
|
387
|
+
ensure
|
|
388
|
+
barrier.stop
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
raise ExecutionError, "Parallel failed: #{errors.size} errors" if wait_mode == "all" && errors.any?
|
|
393
|
+
raise ExecutionError, "Insufficient completions" if outcomes.compact.size < required
|
|
394
|
+
|
|
395
|
+
# Merge contexts from all branches
|
|
396
|
+
# Strategy: last-write-wins (branch processed later overwrites earlier values)
|
|
397
|
+
merged_ctx = outcomes.compact.reduce(state.ctx) do |ctx, outcome|
|
|
398
|
+
ctx.merge(outcome.state.ctx)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
results = outcomes.map { _1&.result&.output }
|
|
402
|
+
final_state = state.with(ctx: merged_ctx)
|
|
403
|
+
final_state = store(final_state, config.output, results)
|
|
404
|
+
|
|
405
|
+
continue(final_state, output: results)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### 8. `lib/durable_workflow/core/executors/transform.rb`
|
|
414
|
+
|
|
415
|
+
```ruby
|
|
416
|
+
# frozen_string_literal: true
|
|
417
|
+
|
|
418
|
+
module DurableWorkflow
|
|
419
|
+
module Core
|
|
420
|
+
module Executors
|
|
421
|
+
class Transform < Base
|
|
422
|
+
Registry.register("transform", self)
|
|
423
|
+
|
|
424
|
+
OPS = {
|
|
425
|
+
"map" => ->(d, a) { d.is_a?(Array) ? d.map { |i| a.is_a?(String) ? Transform.dig(i, a) : i } : d },
|
|
426
|
+
"select" => ->(d, a) { d.is_a?(Array) ? d.select { |i| Transform.match?(i, a) } : d },
|
|
427
|
+
"reject" => ->(d, a) { d.is_a?(Array) ? d.reject { |i| Transform.match?(i, a) } : d },
|
|
428
|
+
"pluck" => ->(d, a) { d.is_a?(Array) ? d.map { |i| Transform.dig(i, a) } : d },
|
|
429
|
+
"first" => ->(d, a) { d.is_a?(Array) ? d.first(a || 1) : d },
|
|
430
|
+
"last" => ->(d, a) { d.is_a?(Array) ? d.last(a || 1) : d },
|
|
431
|
+
"flatten" => ->(d, a) { d.is_a?(Array) ? d.flatten(a || 1) : d },
|
|
432
|
+
"compact" => ->(d, _) { d.is_a?(Array) ? d.compact : d },
|
|
433
|
+
"uniq" => ->(d, _) { d.is_a?(Array) ? d.uniq : d },
|
|
434
|
+
"reverse" => ->(d, _) { d.is_a?(Array) ? d.reverse : d },
|
|
435
|
+
"sort" => ->(d, a) { d.is_a?(Array) ? (a ? d.sort_by { |i| Transform.dig(i, a) } : d.sort) : d },
|
|
436
|
+
"count" => ->(d, _) { d.respond_to?(:size) ? d.size : 1 },
|
|
437
|
+
"sum" => ->(d, a) { d.is_a?(Array) ? (a ? d.sum { |i| Transform.dig(i, a).to_f } : d.sum(&:to_f)) : d },
|
|
438
|
+
"keys" => ->(d, _) { d.is_a?(Hash) ? d.keys : [] },
|
|
439
|
+
"values" => ->(d, _) { d.is_a?(Hash) ? d.values : [] },
|
|
440
|
+
"pick" => ->(d, a) { d.is_a?(Hash) ? d.slice(*Array(a)) : d },
|
|
441
|
+
"omit" => ->(d, a) { d.is_a?(Hash) ? d.except(*Array(a)) : d },
|
|
442
|
+
"merge" => ->(d, a) { d.is_a?(Hash) && a.is_a?(Hash) ? d.merge(a) : d }
|
|
443
|
+
}.freeze
|
|
444
|
+
|
|
445
|
+
def call(state)
|
|
446
|
+
input = config.input ? resolve(state, "$#{config.input}") : state.ctx.dup
|
|
447
|
+
expr = config.expression
|
|
448
|
+
|
|
449
|
+
result = expr.reduce(input) do |data, (op, arg)|
|
|
450
|
+
OPS.fetch(op.to_s) { ->(d, _) { d } }.call(data, arg)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
state = store(state, config.output, result)
|
|
454
|
+
continue(state, output: result)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def self.dig(obj, key)
|
|
458
|
+
keys = key.to_s.split(".")
|
|
459
|
+
keys.reduce(obj) { |o, k| o.is_a?(Hash) ? (o[k] || o[k.to_sym]) : nil }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def self.match?(obj, conditions)
|
|
463
|
+
conditions.all? { |k, v| dig(obj, k) == v }
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### 9. `lib/durable_workflow/core/executors/halt.rb`
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
# frozen_string_literal: true
|
|
475
|
+
|
|
476
|
+
module DurableWorkflow
|
|
477
|
+
module Core
|
|
478
|
+
module Executors
|
|
479
|
+
class Halt < Base
|
|
480
|
+
Registry.register("halt", self)
|
|
481
|
+
|
|
482
|
+
def call(state)
|
|
483
|
+
extra_data = resolve(state, config.data) || {}
|
|
484
|
+
|
|
485
|
+
halt(state,
|
|
486
|
+
data: {
|
|
487
|
+
reason: resolve(state, config.reason) || "Halted",
|
|
488
|
+
halted_at: Time.now.iso8601,
|
|
489
|
+
**extra_data
|
|
490
|
+
},
|
|
491
|
+
resume_step: config.resume_step || next_step,
|
|
492
|
+
prompt: resolve(state, config.reason)
|
|
493
|
+
)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### 10. `lib/durable_workflow/core/executors/approval.rb`
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
# frozen_string_literal: true
|
|
505
|
+
|
|
506
|
+
module DurableWorkflow
|
|
507
|
+
module Core
|
|
508
|
+
module Executors
|
|
509
|
+
class Approval < Base
|
|
510
|
+
Registry.register("approval", self)
|
|
511
|
+
|
|
512
|
+
def call(state)
|
|
513
|
+
# Check if timed out (when resuming)
|
|
514
|
+
requested_at_str = state.ctx.dig(:_halt, :requested_at)
|
|
515
|
+
if requested_at_str && config.timeout
|
|
516
|
+
requested_at = Time.parse(requested_at_str)
|
|
517
|
+
if Time.now - requested_at > config.timeout
|
|
518
|
+
if config.on_timeout
|
|
519
|
+
return continue(state, next_step: config.on_timeout)
|
|
520
|
+
else
|
|
521
|
+
raise ExecutionError, "Approval timeout"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Resuming from approval
|
|
527
|
+
if state.ctx.key?(:approved)
|
|
528
|
+
approved = state.ctx[:approved]
|
|
529
|
+
state = state.with(ctx: state.ctx.except(:approved))
|
|
530
|
+
if approved
|
|
531
|
+
return continue(state)
|
|
532
|
+
elsif config.on_reject
|
|
533
|
+
return continue(state, next_step: config.on_reject)
|
|
534
|
+
else
|
|
535
|
+
raise ExecutionError, "Rejected"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Request approval
|
|
540
|
+
halt(state,
|
|
541
|
+
data: {
|
|
542
|
+
type: :approval,
|
|
543
|
+
prompt: resolve(state, config.prompt),
|
|
544
|
+
context: resolve(state, config.context),
|
|
545
|
+
approvers: config.approvers,
|
|
546
|
+
timeout: config.timeout,
|
|
547
|
+
requested_at: Time.now.iso8601
|
|
548
|
+
},
|
|
549
|
+
resume_step: step.id,
|
|
550
|
+
prompt: resolve(state, config.prompt)
|
|
551
|
+
)
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### 11. `lib/durable_workflow/core/executors/workflow.rb`
|
|
560
|
+
|
|
561
|
+
```ruby
|
|
562
|
+
# frozen_string_literal: true
|
|
563
|
+
|
|
564
|
+
module DurableWorkflow
|
|
565
|
+
module Core
|
|
566
|
+
module Executors
|
|
567
|
+
class SubWorkflow < Base
|
|
568
|
+
Registry.register("workflow", self)
|
|
569
|
+
|
|
570
|
+
def call(state)
|
|
571
|
+
child_wf = DurableWorkflow.registry[config.workflow_id]
|
|
572
|
+
raise ExecutionError, "Workflow not found: #{config.workflow_id}" unless child_wf
|
|
573
|
+
|
|
574
|
+
input = resolve(state, config.input) || {}
|
|
575
|
+
|
|
576
|
+
result = with_timeout(config.timeout) do
|
|
577
|
+
Engine.new(child_wf).run(input)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
case result.status
|
|
581
|
+
when :completed
|
|
582
|
+
state = store(state, config.output, result.output)
|
|
583
|
+
continue(state, output: result.output)
|
|
584
|
+
when :halted
|
|
585
|
+
halt(state, data: result.halt.data, resume_step: step.id, prompt: result.halt.prompt)
|
|
586
|
+
when :failed
|
|
587
|
+
raise ExecutionError, "Sub-workflow failed: #{result.error}"
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## Acceptance Criteria
|
|
597
|
+
|
|
598
|
+
1. All 11 executors are registered in `Executors::Registry`
|
|
599
|
+
2. `Registry.types` returns all core types: start, end, assign, call, router, loop, parallel, transform, halt, approval, workflow
|
|
600
|
+
3. Each executor returns `StepOutcome` with either `ContinueResult` or `HaltResult`
|
|
601
|
+
4. Loop executor bubbles up halts from body
|
|
602
|
+
5. Parallel executor uses async gem for concurrent execution
|
|
603
|
+
6. Approval executor handles timeout on resume
|