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,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