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,551 @@
1
+ # 03-EXECUTION: Engine, Registry, Resolver, Condition
2
+
3
+ ## Goal
4
+
5
+ Implement the execution infrastructure: Engine (orchestrates steps), Executor Registry (maps types to executors), Resolver (resolves $references), and ConditionEvaluator (evaluates routing conditions).
6
+
7
+ ## Dependencies
8
+
9
+ - 01-GEMSPEC completed
10
+ - 02-TYPES completed
11
+
12
+ ## Files to Create
13
+
14
+ ### 1. `lib/durable_workflow/core/executors/registry.rb`
15
+
16
+ ```ruby
17
+ # frozen_string_literal: true
18
+
19
+ module DurableWorkflow
20
+ module Core
21
+ module Executors
22
+ class Registry
23
+ @executors = {}
24
+
25
+ class << self
26
+ def register(type, klass)
27
+ @executors[type.to_s] = klass
28
+ end
29
+
30
+ def [](type)
31
+ @executors[type.to_s]
32
+ end
33
+
34
+ def types
35
+ @executors.keys
36
+ end
37
+
38
+ def registered?(type)
39
+ @executors.key?(type.to_s)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Convenience method for registration
45
+ def self.register(type)
46
+ ->(klass) { Registry.register(type, klass) }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### 2. `lib/durable_workflow/core/executors/base.rb`
54
+
55
+ ```ruby
56
+ # frozen_string_literal: true
57
+
58
+ require "timeout"
59
+
60
+ module DurableWorkflow
61
+ module Core
62
+ module Executors
63
+ class Base
64
+ attr_reader :step, :config
65
+
66
+ def initialize(step)
67
+ @step = step
68
+ @config = step.config
69
+ end
70
+
71
+ # Executors receive state and return StepOutcome
72
+ def call(state)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ private
77
+
78
+ def next_step
79
+ step.next_step
80
+ end
81
+
82
+ # Pure resolve - takes state explicitly
83
+ def resolve(state, v)
84
+ Resolver.resolve(state, v)
85
+ end
86
+
87
+ # Return StepOutcome with continue result
88
+ def continue(state, next_step: nil, output: nil)
89
+ StepOutcome.new(
90
+ state:,
91
+ result: ContinueResult.new(next_step: next_step || self.next_step, output:)
92
+ )
93
+ end
94
+
95
+ # Return StepOutcome with halt result
96
+ def halt(state, data: {}, resume_step: nil, prompt: nil)
97
+ StepOutcome.new(
98
+ state:,
99
+ result: HaltResult.new(data:, resume_step: resume_step || next_step, prompt:)
100
+ )
101
+ end
102
+
103
+ # Immutable store - returns new state
104
+ def store(state, key, val)
105
+ return state unless key
106
+ state.with_ctx(key.to_sym => DurableWorkflow::Utils.deep_symbolize(val))
107
+ end
108
+
109
+ def with_timeout(seconds = nil, &block)
110
+ timeout = seconds || config_timeout
111
+ return yield unless timeout
112
+
113
+ Timeout.timeout(timeout) { yield }
114
+ rescue Timeout::Error
115
+ raise ExecutionError, "Step '#{step.id}' timed out after #{timeout}s"
116
+ end
117
+
118
+ def with_retry(max_retries: 0, delay: 1.0, backoff: 2.0, &block)
119
+ attempts = 0
120
+ begin
121
+ attempts += 1
122
+ yield
123
+ rescue => e
124
+ if attempts <= max_retries
125
+ sleep_time = delay * (backoff ** (attempts - 1))
126
+ log(:warn, "Retry #{attempts}/#{max_retries} after #{sleep_time}s", error: e.message)
127
+ sleep(sleep_time)
128
+ retry
129
+ end
130
+ raise
131
+ end
132
+ end
133
+
134
+ def config_timeout
135
+ config.respond_to?(:timeout) ? config.timeout : nil
136
+ end
137
+
138
+ def log(level, msg, **data)
139
+ DurableWorkflow.log(level, msg, step_id: step.id, step_type: step.type, **data)
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ ```
146
+
147
+ ### 3. `lib/durable_workflow/core/resolver.rb`
148
+
149
+ ```ruby
150
+ # frozen_string_literal: true
151
+
152
+ module DurableWorkflow
153
+ module Core
154
+ # Stateless resolver - all methods take state explicitly
155
+ class Resolver
156
+ PATTERN = /\$([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)/
157
+
158
+ class << self
159
+ def resolve(state, value)
160
+ case value
161
+ when String then resolve_string(state, value)
162
+ when Hash then value.transform_values { resolve(state, _1) }
163
+ when Array then value.map { resolve(state, _1) }
164
+ when nil then nil
165
+ else value
166
+ end
167
+ end
168
+
169
+ def resolve_ref(state, ref)
170
+ parts = ref.split(".")
171
+ root = parts.shift.to_sym
172
+
173
+ base = case root
174
+ when :input then state.input
175
+ when :now then return Time.now
176
+ when :history then return state.history
177
+ else state.ctx[root]
178
+ end
179
+
180
+ dig(base, parts)
181
+ end
182
+
183
+ private
184
+
185
+ def resolve_string(state, str)
186
+ # Whole string is single reference -> return actual value (not stringified)
187
+ return resolve_ref(state, str[1..]) if str.match?(/\A\$[a-zA-Z_][a-zA-Z0-9_.]*\z/)
188
+
189
+ # Embedded references -> interpolate as strings
190
+ str.gsub(PATTERN) { resolve_ref(state, _1[1..]).to_s }
191
+ end
192
+
193
+ def dig(value, keys)
194
+ return value if keys.empty?
195
+ key = keys.shift
196
+
197
+ next_val = case value
198
+ when Hash then value[key.to_sym] || value[key]
199
+ when Array then key.match?(/\A\d+\z/) ? value[key.to_i] : nil
200
+ when Struct then value.respond_to?(key) ? value.send(key) : nil
201
+ else value.respond_to?(key) ? value.send(key) : nil
202
+ end
203
+
204
+ dig(next_val, keys)
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ ```
211
+
212
+ ### 4. `lib/durable_workflow/core/condition.rb`
213
+
214
+ ```ruby
215
+ # frozen_string_literal: true
216
+
217
+ module DurableWorkflow
218
+ module Core
219
+ # Stateless condition evaluator
220
+ class ConditionEvaluator
221
+ OPS = {
222
+ "eq" => ->(v, e) { v == e },
223
+ "neq" => ->(v, e) { v != e },
224
+ "gt" => ->(v, e) { v.to_f > e.to_f },
225
+ "lt" => ->(v, e) { v.to_f < e.to_f },
226
+ "gte" => ->(v, e) { v.to_f >= e.to_f },
227
+ "lte" => ->(v, e) { v.to_f <= e.to_f },
228
+ "in" => ->(v, e) { Array(e).include?(v) },
229
+ "not_in" => ->(v, e) { !Array(e).include?(v) },
230
+ "contains" => ->(v, e) { v.to_s.include?(e.to_s) },
231
+ "starts_with" => ->(v, e) { v.to_s.start_with?(e.to_s) },
232
+ "ends_with" => ->(v, e) { v.to_s.end_with?(e.to_s) },
233
+ "matches" => ->(v, e) { v.to_s.match?(Regexp.new(e.to_s)) },
234
+ "exists" => ->(v, _) { !v.nil? },
235
+ "empty" => ->(v, _) { v.nil? || (v.respond_to?(:empty?) && v.empty?) },
236
+ "truthy" => ->(v, _) { !!v },
237
+ "falsy" => ->(v, _) { !v }
238
+ }.freeze
239
+
240
+ class << self
241
+ # Evaluate Route or Condition
242
+ def match?(state, cond)
243
+ val = Resolver.resolve(state, "$#{cond.field}")
244
+ exp = Resolver.resolve(state, cond.value)
245
+ op = OPS.fetch(cond.op) { ->(_, _) { false } }
246
+ op.call(val, exp)
247
+ rescue => e
248
+ DurableWorkflow.log(:warn, "Condition failed: #{e.message}", field: cond.field, op: cond.op)
249
+ false
250
+ end
251
+
252
+ # Find first matching route
253
+ def find_route(state, routes)
254
+ routes.find { match?(state, _1) }
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ ```
261
+
262
+ ### 5. `lib/durable_workflow/core/validator.rb`
263
+
264
+ ```ruby
265
+ # frozen_string_literal: true
266
+
267
+ module DurableWorkflow
268
+ module Core
269
+ class Validator
270
+ FINISHED = "__FINISHED__"
271
+
272
+ def self.validate!(workflow)
273
+ new(workflow).validate!
274
+ end
275
+
276
+ def initialize(workflow)
277
+ @wf = workflow
278
+ @errors = []
279
+ end
280
+
281
+ def validate!
282
+ check_unique_ids!
283
+ check_step_types!
284
+ check_references!
285
+ check_reachability!
286
+ raise ValidationError, @errors.join("; ") if @errors.any?
287
+ true
288
+ end
289
+
290
+ def valid?
291
+ validate!
292
+ rescue ValidationError
293
+ false
294
+ end
295
+
296
+ private
297
+
298
+ def check_unique_ids!
299
+ ids = @wf.steps.map(&:id)
300
+ dups = ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
301
+ @errors << "Duplicate step IDs: #{dups.join(', ')}" if dups.any?
302
+ end
303
+
304
+ def check_step_types!
305
+ @wf.steps.each do |step|
306
+ unless Executors::Registry.registered?(step.type)
307
+ @errors << "Unknown step type '#{step.type}' in step '#{step.id}'"
308
+ end
309
+ end
310
+ end
311
+
312
+ def check_references!
313
+ valid_ids = @wf.step_ids.to_set << FINISHED
314
+
315
+ @wf.steps.each do |step|
316
+ check_ref(step.id, "next", step.next_step, valid_ids)
317
+ check_ref(step.id, "on_error", step.on_error, valid_ids)
318
+
319
+ cfg = step.config
320
+ case step.type
321
+ when "router"
322
+ cfg.routes&.each { |r| check_ref(step.id, "route", r.target, valid_ids) }
323
+ check_ref(step.id, "default", cfg.default, valid_ids)
324
+ when "loop"
325
+ check_ref(step.id, "on_exhausted", cfg.on_exhausted, valid_ids)
326
+ cfg.do&.each { |s| check_ref(step.id, "loop.do", s.next_step, valid_ids) }
327
+ when "parallel"
328
+ cfg.branches&.each { |s| check_ref(step.id, "branch", s.next_step, valid_ids) }
329
+ when "halt"
330
+ check_ref(step.id, "resume_step", cfg.resume_step, valid_ids)
331
+ when "approval"
332
+ check_ref(step.id, "on_reject", cfg.on_reject, valid_ids)
333
+ check_ref(step.id, "on_timeout", cfg.on_timeout, valid_ids) if cfg.respond_to?(:on_timeout)
334
+ end
335
+ end
336
+ end
337
+
338
+ def check_ref(step_id, field, target, valid_ids)
339
+ return unless target
340
+ return if valid_ids.include?(target)
341
+ @errors << "Step '#{step_id}' #{field} references unknown '#{target}'"
342
+ end
343
+
344
+ def check_reachability!
345
+ return if @wf.steps.empty?
346
+
347
+ reachable = Set.new
348
+ queue = [@wf.first_step.id]
349
+
350
+ while (id = queue.shift)
351
+ next if reachable.include?(id) || id == FINISHED
352
+ reachable << id
353
+ step = @wf.find_step(id)
354
+ next unless step
355
+
356
+ queue << step.next_step if step.next_step
357
+ queue << step.on_error if step.on_error
358
+
359
+ cfg = step.config
360
+ case step.type
361
+ when "router"
362
+ cfg.routes&.each { |r| queue << r.target }
363
+ queue << cfg.default if cfg.default
364
+ when "loop"
365
+ cfg.do&.each { |s| queue << s.id }
366
+ queue << cfg.on_exhausted if cfg.on_exhausted
367
+ when "parallel"
368
+ cfg.branches&.each { |s| queue << s.id }
369
+ when "approval"
370
+ queue << cfg.on_reject if cfg.on_reject
371
+ queue << cfg.on_timeout if cfg.respond_to?(:on_timeout) && cfg.on_timeout
372
+ end
373
+ end
374
+
375
+ unreachable = @wf.step_ids - reachable.to_a
376
+ @errors << "Unreachable steps: #{unreachable.join(', ')}" if unreachable.any?
377
+ end
378
+ end
379
+ end
380
+ end
381
+ ```
382
+
383
+ ### 6. `lib/durable_workflow/core/engine.rb`
384
+
385
+ ```ruby
386
+ # frozen_string_literal: true
387
+
388
+ require "timeout"
389
+
390
+ module DurableWorkflow
391
+ module Core
392
+ class Engine
393
+ FINISHED = "__FINISHED__"
394
+
395
+ attr_reader :workflow, :store
396
+
397
+ def initialize(workflow, store: nil)
398
+ @workflow = workflow
399
+ @store = store || DurableWorkflow.config&.store
400
+ raise ConfigError, "No store configured. Use Redis, ActiveRecord, or Sequel." unless @store
401
+ end
402
+
403
+ def run(input, execution_id: nil)
404
+ exec_id = execution_id || SecureRandom.uuid
405
+
406
+ state = State.new(
407
+ execution_id: exec_id,
408
+ workflow_id: workflow.id,
409
+ input: DurableWorkflow::Utils.deep_symbolize(input || {})
410
+ )
411
+
412
+ # Save initial Execution with :running status
413
+ save_execution(state, ExecutionResult.new(status: :running, execution_id: exec_id))
414
+
415
+ if workflow.timeout
416
+ Timeout.timeout(workflow.timeout) do
417
+ execute_from(state, workflow.first_step.id)
418
+ end
419
+ else
420
+ execute_from(state, workflow.first_step.id)
421
+ end
422
+ rescue Timeout::Error
423
+ result = ExecutionResult.new(status: :failed, execution_id: state.execution_id, error: "Workflow timeout after #{workflow.timeout}s")
424
+ save_execution(state, result)
425
+ result
426
+ end
427
+
428
+ def resume(execution_id, response: nil, approved: nil)
429
+ execution = @store.load(execution_id)
430
+ raise ExecutionError, "Execution not found: #{execution_id}" unless execution
431
+
432
+ state = execution.to_state
433
+ state = state.with_ctx(response:) if response
434
+ state = state.with_ctx(approved:) unless approved.nil?
435
+
436
+ # Use recover_to from Execution, or fall back to current_step
437
+ resume_step = execution.recover_to || execution.current_step
438
+
439
+ execute_from(state, resume_step)
440
+ end
441
+
442
+ private
443
+
444
+ def execute_from(state, step_id)
445
+ while step_id && step_id != FINISHED
446
+ state = state.with_current_step(step_id)
447
+
448
+ # Save intermediate state as :running
449
+ save_execution(state, ExecutionResult.new(status: :running, execution_id: state.execution_id))
450
+
451
+ step = workflow.find_step(step_id)
452
+ raise ExecutionError, "Step not found: #{step_id}" unless step
453
+
454
+ outcome = execute_step(state, step)
455
+ state = outcome.state
456
+
457
+ case outcome.result
458
+ when HaltResult
459
+ return handle_halt(state, outcome.result)
460
+ when ContinueResult
461
+ step_id = outcome.result.next_step
462
+ else
463
+ raise ExecutionError, "Unknown result: #{outcome.result.class}"
464
+ end
465
+ end
466
+
467
+ # Completed
468
+ result = ExecutionResult.new(status: :completed, execution_id: state.execution_id, output: state.ctx[:result])
469
+ save_execution(state, result)
470
+ result
471
+ end
472
+
473
+ def execute_step(state, step)
474
+ executor_class = Executors::Registry[step.type]
475
+ raise ExecutionError, "No executor for: #{step.type}" unless executor_class
476
+
477
+ start = Time.now
478
+ outcome = executor_class.new(step).call(state)
479
+ duration = ((Time.now - start) * 1000).to_i
480
+
481
+ @store.record(Entry.new(
482
+ id: SecureRandom.uuid,
483
+ execution_id: state.execution_id,
484
+ step_id: step.id,
485
+ step_type: step.type,
486
+ action: outcome.result.is_a?(HaltResult) ? :halted : :completed,
487
+ duration_ms: duration,
488
+ output: outcome.result.output,
489
+ timestamp: Time.now
490
+ ))
491
+
492
+ outcome
493
+ rescue => e
494
+ @store.record(Entry.new(
495
+ id: SecureRandom.uuid,
496
+ execution_id: state.execution_id,
497
+ step_id: step.id,
498
+ step_type: step.type,
499
+ action: :failed,
500
+ error: "#{e.class}: #{e.message}",
501
+ timestamp: Time.now
502
+ ))
503
+
504
+ if step.on_error
505
+ # Store error info in ctx for access by error handler step
506
+ error_state = state.with_ctx(_last_error: { step: step.id, message: e.message, class: e.class.name })
507
+ return StepOutcome.new(state: error_state, result: ContinueResult.new(next_step: step.on_error))
508
+ end
509
+
510
+ raise
511
+ end
512
+
513
+ def handle_halt(state, halt_result)
514
+ result = ExecutionResult.new(
515
+ status: :halted,
516
+ execution_id: state.execution_id,
517
+ output: state.ctx[:result],
518
+ halt: halt_result
519
+ )
520
+ save_execution(state, result)
521
+ result
522
+ end
523
+
524
+ def save_execution(state, result)
525
+ execution = Execution.from_state(state, result)
526
+ @store.save(execution)
527
+ end
528
+ end
529
+ end
530
+ end
531
+ ```
532
+
533
+ ## Key Changes from Original
534
+
535
+ 1. **Validator now checks step types against Registry** - `check_step_types!` method ensures all types are registered
536
+ 2. **No AI-specific handling in Validator** - removed `guardrail` check in `check_reachability!`
537
+ 3. **Module namespace is `DurableWorkflow`** - not `Workflow`
538
+ 4. **Engine saves Execution, not State** - `save_execution(state, result)` converts State + ExecutionResult → Execution
539
+ 5. **Engine loads Execution, converts to State** - `execution.to_state` for executor use
540
+ 6. **No `ctx[:_status]`, `ctx[:_halt]`, `ctx[:_resume_step]`** - all in typed Execution fields
541
+ 7. **`_last_error` in ctx for error handler access** - only temporary, for on_error step to read
542
+
543
+ ## Acceptance Criteria
544
+
545
+ 1. `Executors::Registry.register("custom", MyExecutor)` works
546
+ 2. `Executors::Registry.registered?("custom")` returns true
547
+ 3. `Validator.validate!` fails for unknown step types
548
+ 4. `Engine.new(wf).run(input)` executes steps in sequence
549
+ 5. `Engine.resume(id, approved: true)` continues halted workflow
550
+ 6. Store receives `Execution` objects (not State) with typed `status`, `halt_data`, `error`, `recover_to`
551
+ 7. `ctx` only contains user workflow variables (except transient `_last_error` for error handling)