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,719 @@
1
+ # 05-PARSER: Parser with Schema Validation Support
2
+
3
+ ## Goal
4
+
5
+ Implement the YAML parser with:
6
+
7
+ 1. Hook system for extensions to inject parsing logic
8
+ 2. Schema validation support for outputs
9
+ 3. Variable reachability checking
10
+
11
+ ## Dependencies
12
+
13
+ - 01-GEMSPEC completed
14
+ - 02-TYPES completed
15
+ - 03-EXECUTION completed
16
+
17
+ ## Files to Create
18
+
19
+ ### 1. `lib/durable_workflow/core/types/configs.rb` (Update - add OutputConfig)
20
+
21
+ Add to the existing configs.rb:
22
+
23
+ ```ruby
24
+ # frozen_string_literal: true
25
+
26
+ module DurableWorkflow
27
+ module Core
28
+ # ... existing StepConfig class ...
29
+
30
+ # Output with optional schema validation
31
+ class OutputConfig < BaseStruct
32
+ attribute :key, Types::Coercible::Symbol
33
+ attribute? :schema, Types::Hash.optional
34
+ end
35
+
36
+ # Update CallConfig to support schema'd output
37
+ class CallConfig < StepConfig
38
+ attribute :service, Types::Strict::String
39
+ attribute :method_name, Types::Strict::String
40
+ attribute? :input, Types::Any
41
+ attribute? :output, Types::Coercible::Symbol.optional | OutputConfig
42
+ attribute? :timeout, Types::Strict::Integer.optional
43
+ attribute? :retries, Types::Strict::Integer.optional.default(0)
44
+ attribute? :retry_delay, Types::Strict::Float.optional.default(1.0)
45
+ attribute? :retry_backoff, Types::Strict::Float.optional.default(2.0)
46
+ end
47
+
48
+ # ... rest of existing configs ...
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### 2. `lib/durable_workflow/core/parser.rb`
54
+
55
+ ```ruby
56
+ # frozen_string_literal: true
57
+
58
+ require "yaml"
59
+
60
+ module DurableWorkflow
61
+ module Core
62
+ class Parser
63
+ # Hook system for extensions
64
+ @before_hooks = []
65
+ @after_hooks = []
66
+ @config_transformers = {}
67
+
68
+ class << self
69
+ attr_reader :before_hooks, :after_hooks, :config_transformers
70
+
71
+ def parse(source)
72
+ new.parse(source)
73
+ end
74
+
75
+ # Register a before-parse hook (receives raw YAML hash)
76
+ def before_parse(&block)
77
+ @before_hooks << block
78
+ end
79
+
80
+ # Register an after-parse hook (receives WorkflowDef, can return modified)
81
+ def after_parse(&block)
82
+ @after_hooks << block
83
+ end
84
+
85
+ # Register a config transformer for a step type
86
+ def transform_config(type, &block)
87
+ @config_transformers[type.to_s] = block
88
+ end
89
+ end
90
+
91
+ def parse(source)
92
+ yaml = load_yaml(source)
93
+
94
+ # Run before hooks
95
+ self.class.before_hooks.each { |hook| yaml = hook.call(yaml) || yaml }
96
+
97
+ workflow = build_workflow(yaml)
98
+
99
+ # Run after hooks
100
+ self.class.after_hooks.each { |hook| workflow = hook.call(workflow) || workflow }
101
+
102
+ workflow
103
+ end
104
+
105
+ private
106
+
107
+ def load_yaml(source)
108
+ raw = case source
109
+ when Hash then source
110
+ when String
111
+ source.include?("\n") ? YAML.safe_load(source) : YAML.load_file(source)
112
+ else
113
+ raise Error, "Invalid source: #{source.class}"
114
+ end
115
+ DurableWorkflow::Utils.deep_symbolize(raw)
116
+ end
117
+
118
+ def build_workflow(y)
119
+ WorkflowDef.new(
120
+ id: y.fetch(:id),
121
+ name: y.fetch(:name),
122
+ version: y[:version],
123
+ description: y[:description],
124
+ timeout: y[:timeout],
125
+ inputs: parse_inputs(y[:inputs]),
126
+ steps: parse_steps(y.fetch(:steps)),
127
+ extensions: {} # Extensions populate this via after_parse hooks
128
+ )
129
+ end
130
+
131
+ def parse_inputs(inputs)
132
+ return [] unless inputs
133
+ inputs.map do |name, cfg|
134
+ cfg ||= {}
135
+ InputDef.new(
136
+ name: name.to_s,
137
+ type: cfg[:type],
138
+ required: cfg.fetch(:required, true),
139
+ default: cfg[:default],
140
+ description: cfg[:description]
141
+ )
142
+ end
143
+ end
144
+
145
+ def parse_steps(steps)
146
+ steps.map { parse_step(_1) }
147
+ end
148
+
149
+ def parse_step(s)
150
+ type = s.fetch(:type)
151
+ raw_config = extract_config(s)
152
+ config = build_typed_config(type, raw_config)
153
+
154
+ StepDef.new(
155
+ id: s.fetch(:id),
156
+ type:,
157
+ config:,
158
+ next_step: s[:next],
159
+ on_error: s[:on_error]
160
+ )
161
+ rescue Dry::Struct::Error => e
162
+ raise ValidationError, "Invalid config for step '#{s[:id]}': #{e.message}"
163
+ end
164
+
165
+ def build_typed_config(type, raw_config)
166
+ # Check for extension transformer first
167
+ if (transformer = self.class.config_transformers[type])
168
+ raw_config = transformer.call(raw_config)
169
+ end
170
+
171
+ # Find config class from core registry
172
+ config_class = CONFIG_REGISTRY[type]
173
+
174
+ # If not found, check extension registries
175
+ unless config_class
176
+ # Extensions register their configs via Core.register_config
177
+ config_class = CONFIG_REGISTRY[type]
178
+ end
179
+
180
+ config_class ? config_class.new(raw_config) : raw_config
181
+ end
182
+
183
+ def extract_config(s)
184
+ base = s.reject { |k, _| %i[id type next on_error].include?(k) }
185
+
186
+ case s[:type]
187
+ when "call"
188
+ # Rename method -> method_name to avoid collision with Ruby's Object#method
189
+ base[:method_name] = base.delete(:method) if base.key?(:method)
190
+ # Handle output with schema
191
+ base[:output] = parse_output(base[:output]) if base[:output]
192
+ when "router"
193
+ base[:routes] = parse_routes(base[:routes])
194
+ when "loop"
195
+ base[:while] = parse_condition(base[:while]) if base[:while]
196
+ base[:do] = base[:do]&.map { parse_step(_1) }
197
+ when "parallel"
198
+ base[:branches] = base[:branches]&.map { parse_step(_1) }
199
+ end
200
+
201
+ base
202
+ end
203
+
204
+ def parse_output(output)
205
+ case output
206
+ when Hash
207
+ if output.key?(:key) || output.key?(:schema)
208
+ OutputConfig.new(
209
+ key: output[:key] || output[:name],
210
+ schema: output[:schema]
211
+ )
212
+ else
213
+ output
214
+ end
215
+ when String, Symbol
216
+ output.to_sym
217
+ else
218
+ output
219
+ end
220
+ end
221
+
222
+ def parse_routes(routes)
223
+ return [] unless routes
224
+ routes.map do |r|
225
+ Route.new(
226
+ field: r.dig(:when, :field),
227
+ op: r.dig(:when, :op),
228
+ value: r.dig(:when, :value),
229
+ target: r[:then]
230
+ )
231
+ end
232
+ end
233
+
234
+ def parse_condition(c)
235
+ return nil unless c
236
+ Condition.new(field: c[:field], op: c[:op], value: c[:value])
237
+ end
238
+ end
239
+ end
240
+ end
241
+ ```
242
+
243
+ ### 3. `lib/durable_workflow/core/validator.rb` (Enhanced)
244
+
245
+ ```ruby
246
+ # frozen_string_literal: true
247
+
248
+ module DurableWorkflow
249
+ module Core
250
+ class Validator
251
+ FINISHED = "__FINISHED__"
252
+
253
+ def self.validate!(workflow)
254
+ new(workflow).validate!
255
+ end
256
+
257
+ def initialize(workflow)
258
+ @workflow = workflow
259
+ @errors = []
260
+ @step_index = workflow.steps.to_h { [_1.id, _1] }
261
+ @schemas = {} # step_id -> output schema
262
+ end
263
+
264
+ def validate!
265
+ check_unique_ids!
266
+ check_step_types!
267
+ check_references!
268
+ check_variable_reachability!
269
+ check_schema_compatibility!
270
+ check_reachability!
271
+
272
+ raise ValidationError, format_errors if @errors.any?
273
+ true
274
+ end
275
+
276
+ def valid?
277
+ validate!
278
+ rescue ValidationError
279
+ false
280
+ end
281
+
282
+ private
283
+
284
+ # ─────────────────────────────────────────────────────────────
285
+ # 0. Unique IDs
286
+ # ─────────────────────────────────────────────────────────────
287
+
288
+ def check_unique_ids!
289
+ ids = @workflow.steps.map(&:id)
290
+ dups = ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
291
+ @errors << "Duplicate step IDs: #{dups.join(', ')}" if dups.any?
292
+ end
293
+
294
+ # ─────────────────────────────────────────────────────────────
295
+ # 1. Step Types Registered
296
+ # ─────────────────────────────────────────────────────────────
297
+
298
+ def check_step_types!
299
+ @workflow.steps.each do |step|
300
+ unless Executors::Registry.registered?(step.type)
301
+ @errors << "Unknown step type '#{step.type}' in step '#{step.id}'"
302
+ end
303
+ end
304
+ end
305
+
306
+ # ─────────────────────────────────────────────────────────────
307
+ # 2. Step References Exist
308
+ # ─────────────────────────────────────────────────────────────
309
+
310
+ def check_references!
311
+ valid_ids = @step_index.keys.to_set << FINISHED
312
+
313
+ @workflow.steps.each do |step|
314
+ check_ref(step.id, "next", step.next_step, valid_ids)
315
+ check_ref(step.id, "on_error", step.on_error, valid_ids)
316
+
317
+ cfg = step.config
318
+ case step.type
319
+ when "router"
320
+ cfg.routes&.each_with_index { |r, i| check_ref(step.id, "route[#{i}]", r.target, valid_ids) }
321
+ check_ref(step.id, "default", cfg.default, valid_ids)
322
+ when "loop"
323
+ check_ref(step.id, "on_exhausted", cfg.on_exhausted, valid_ids)
324
+ cfg.do&.each { |s| check_ref(step.id, "loop.do", s.next_step, valid_ids) }
325
+ when "parallel"
326
+ cfg.branches&.each { |s| check_ref(step.id, "branch", s.next_step, valid_ids) }
327
+ when "halt"
328
+ check_ref(step.id, "resume_step", cfg.resume_step, valid_ids)
329
+ when "approval"
330
+ check_ref(step.id, "on_reject", cfg.on_reject, valid_ids)
331
+ check_ref(step.id, "on_timeout", cfg.on_timeout, valid_ids) if cfg.respond_to?(:on_timeout)
332
+ end
333
+ end
334
+ end
335
+
336
+ def check_ref(step_id, field, target, valid_ids)
337
+ return unless target
338
+ return if valid_ids.include?(target)
339
+ @errors << "Step '#{step_id}' #{field}: references unknown step '#{target}'"
340
+ end
341
+
342
+ # ─────────────────────────────────────────────────────────────
343
+ # 3. Variable Reachability
344
+ # ─────────────────────────────────────────────────────────────
345
+
346
+ def check_variable_reachability!
347
+ # Start with workflow inputs available
348
+ initial = Set.new(@workflow.inputs.map { _1.name.to_sym })
349
+ initial << :input # $input always available
350
+ initial << :now # $now always available
351
+ initial << :history # $history always available
352
+
353
+ first = @workflow.first_step
354
+ walk_steps(first, initial, Set.new) if first
355
+ end
356
+
357
+ def walk_steps(step, available, visited)
358
+ return if step.nil?
359
+
360
+ step_key = step.is_a?(String) ? step : step.id
361
+ step = @step_index[step_key] if step.is_a?(String)
362
+ return unless step
363
+ return if visited.include?(step.id)
364
+
365
+ visited = visited.dup << step.id
366
+
367
+ # Check references in this step
368
+ check_variable_references(step, available)
369
+
370
+ # Collect output schema if present
371
+ collect_schema(step)
372
+
373
+ # Add output to available set
374
+ available = available.dup
375
+ add_step_output(step, available)
376
+
377
+ # Recurse to all possible next steps
378
+ next_steps_for(step).each do |next_id|
379
+ walk_steps(next_id, available, visited)
380
+ end
381
+ end
382
+
383
+ def check_variable_references(step, available)
384
+ refs = extract_refs(step.config)
385
+
386
+ refs.each do |ref|
387
+ root = ref.split('.').first.to_sym
388
+ next if available.include?(root)
389
+
390
+ @errors << "Step '#{step.id}': references '$#{ref}' but '#{root}' not set by preceding step"
391
+ end
392
+ end
393
+
394
+ def add_step_output(step, available)
395
+ return unless step.config.respond_to?(:output) && step.config.output
396
+
397
+ key = case step.config.output
398
+ when Symbol, String then step.config.output
399
+ when OutputConfig then step.config.output.key
400
+ when Hash then step.config.output[:key]
401
+ end
402
+
403
+ available << key.to_sym if key
404
+ end
405
+
406
+ def next_steps_for(step)
407
+ steps = []
408
+ steps << step.next_step if step.next_step
409
+ steps << step.on_error if step.on_error
410
+
411
+ case step.type
412
+ when "router"
413
+ steps.concat(step.config.routes.map(&:target))
414
+ steps << step.config.default if step.config.default
415
+ when "loop"
416
+ steps << step.config.on_exhausted if step.config.on_exhausted
417
+ when "approval"
418
+ steps << step.config.on_reject if step.config.on_reject
419
+ steps << step.config.on_timeout if step.config.respond_to?(:on_timeout) && step.config.on_timeout
420
+ end
421
+
422
+ steps.compact.uniq
423
+ end
424
+
425
+ def extract_refs(obj, refs = [])
426
+ case obj
427
+ when String
428
+ obj.scan(/\$([a-zA-Z_][a-zA-Z0-9_.]*)/).flatten.each { refs << _1 }
429
+ when Hash
430
+ obj.each_value { extract_refs(_1, refs) }
431
+ when Array
432
+ obj.each { extract_refs(_1, refs) }
433
+ when BaseStruct
434
+ obj.to_h.each_value { extract_refs(_1, refs) }
435
+ end
436
+ refs
437
+ end
438
+
439
+ # ─────────────────────────────────────────────────────────────
440
+ # 4. Schema Compatibility
441
+ # ─────────────────────────────────────────────────────────────
442
+
443
+ def collect_schema(step)
444
+ return unless step.config.respond_to?(:output)
445
+
446
+ output = step.config.output
447
+ schema = case output
448
+ when OutputConfig then output.schema
449
+ when Hash then output[:schema]
450
+ end
451
+
452
+ return unless schema
453
+
454
+ key = case output
455
+ when OutputConfig then output.key
456
+ when Hash then output[:key]
457
+ end
458
+
459
+ @schemas[key.to_sym] = schema if key
460
+ end
461
+
462
+ def check_schema_compatibility!
463
+ return if @schemas.empty?
464
+
465
+ @workflow.steps.each do |step|
466
+ refs = extract_refs(step.config)
467
+
468
+ refs.each do |ref|
469
+ parts = ref.split('.')
470
+ root = parts.first.to_sym
471
+
472
+ next unless @schemas.key?(root)
473
+ next if parts.size == 1 # Just $foo, not $foo.bar
474
+
475
+ validate_path_against_schema(step.id, ref, @schemas[root], parts[1..])
476
+ end
477
+ end
478
+ end
479
+
480
+ def validate_path_against_schema(step_id, full_ref, schema, path)
481
+ current = schema
482
+
483
+ path.each do |segment|
484
+ props = current[:properties] || current["properties"]
485
+ unless props
486
+ @errors << "Step '#{step_id}': '$#{full_ref}' — schema has no properties"
487
+ return
488
+ end
489
+
490
+ prop = props[segment.to_sym] || props[segment.to_s]
491
+ unless prop
492
+ available = props.keys.join(', ')
493
+ @errors << "Step '#{step_id}': '$#{full_ref}' — '#{segment}' not in schema (available: #{available})"
494
+ return
495
+ end
496
+
497
+ current = prop
498
+ end
499
+ end
500
+
501
+ # ─────────────────────────────────────────────────────────────
502
+ # 5. Reachability
503
+ # ─────────────────────────────────────────────────────────────
504
+
505
+ def check_reachability!
506
+ return if @workflow.steps.empty?
507
+
508
+ reachable = Set.new
509
+ queue = [@workflow.first_step.id]
510
+
511
+ while (id = queue.shift)
512
+ next if reachable.include?(id) || id == FINISHED
513
+ reachable << id
514
+ step = @step_index[id]
515
+ next unless step
516
+
517
+ queue << step.next_step if step.next_step
518
+ queue << step.on_error if step.on_error
519
+
520
+ cfg = step.config
521
+ case step.type
522
+ when "router"
523
+ cfg.routes&.each { |r| queue << r.target }
524
+ queue << cfg.default if cfg.default
525
+ when "loop"
526
+ cfg.do&.each { |s| queue << s.id }
527
+ queue << cfg.on_exhausted if cfg.on_exhausted
528
+ when "parallel"
529
+ cfg.branches&.each { |s| queue << s.id }
530
+ when "approval"
531
+ queue << cfg.on_reject if cfg.on_reject
532
+ queue << cfg.on_timeout if cfg.respond_to?(:on_timeout) && cfg.on_timeout
533
+ end
534
+ end
535
+
536
+ unreachable = @workflow.step_ids - reachable.to_a
537
+ @errors << "Unreachable steps: #{unreachable.join(', ')}" if unreachable.any?
538
+ end
539
+
540
+ # ─────────────────────────────────────────────────────────────
541
+ # Error Formatting
542
+ # ─────────────────────────────────────────────────────────────
543
+
544
+ def format_errors
545
+ [
546
+ "Workflow '#{@workflow.id}' validation failed:",
547
+ *@errors.map { " - #{_1}" }
548
+ ].join("\n")
549
+ end
550
+ end
551
+ end
552
+ end
553
+ ```
554
+
555
+ ### 4. `lib/durable_workflow/core/schema_validator.rb` (Runtime)
556
+
557
+ ```ruby
558
+ # frozen_string_literal: true
559
+
560
+ module DurableWorkflow
561
+ module Core
562
+ # Runtime JSON Schema validation (optional - requires json_schemer gem)
563
+ class SchemaValidator
564
+ def self.validate!(value, schema, context:)
565
+ return true if schema.nil?
566
+
567
+ begin
568
+ require 'json_schemer'
569
+ rescue LoadError
570
+ # If json_schemer not available, skip runtime validation
571
+ DurableWorkflow.log(:debug, "json_schemer not available, skipping runtime schema validation")
572
+ return true
573
+ end
574
+
575
+ schemer = JSONSchemer.schema(normalize(schema))
576
+ errors = schemer.validate(jsonify(value)).to_a
577
+
578
+ return true if errors.empty?
579
+
580
+ messages = errors.map { _1['error'] }.join('; ')
581
+ raise ValidationError, "#{context}: #{messages}"
582
+ end
583
+
584
+ def self.normalize(schema)
585
+ deep_stringify(schema)
586
+ end
587
+
588
+ def self.jsonify(value)
589
+ JSON.parse(value.to_json)
590
+ end
591
+
592
+ def self.deep_stringify(obj)
593
+ case obj
594
+ when Hash
595
+ obj.transform_keys(&:to_s).transform_values { deep_stringify(_1) }
596
+ when Array
597
+ obj.map { deep_stringify(_1) }
598
+ else
599
+ obj
600
+ end
601
+ end
602
+ end
603
+ end
604
+ end
605
+ ```
606
+
607
+ ### 5. Update `lib/durable_workflow/core/executors/call.rb` (Schema validation)
608
+
609
+ Add schema validation to the Call executor:
610
+
611
+ ```ruby
612
+ # frozen_string_literal: true
613
+
614
+ module DurableWorkflow
615
+ module Core
616
+ module Executors
617
+ class Call < Base
618
+ Registry.register("call", self)
619
+
620
+ def call(state)
621
+ svc = resolve_service(config.service)
622
+ method = config.method_name
623
+ input = resolve(state, config.input)
624
+
625
+ result = with_retry(
626
+ max_retries: config.retries,
627
+ delay: config.retry_delay,
628
+ backoff: config.retry_backoff
629
+ ) do
630
+ with_timeout { invoke(svc, method, input) }
631
+ end
632
+
633
+ # Runtime schema validation (if schema defined)
634
+ validate_output!(result) if output_schema
635
+
636
+ state = store(state, output_key, result)
637
+ continue(state, output: result)
638
+ end
639
+
640
+ private
641
+
642
+ def resolve_service(name)
643
+ DurableWorkflow.config&.service_resolver&.call(name) || Object.const_get(name)
644
+ end
645
+
646
+ def invoke(svc, method, input)
647
+ target = svc.respond_to?(method) ? svc : svc.new
648
+ m = target.method(method)
649
+
650
+ has_kwargs = m.parameters.any? { |type, _| type == :key || type == :keyreq || type == :keyrest }
651
+
652
+ if has_kwargs && input.is_a?(Hash)
653
+ m.call(**input.transform_keys(&:to_sym))
654
+ elsif m.arity == 0
655
+ m.call
656
+ else
657
+ m.call(input)
658
+ end
659
+ end
660
+
661
+ def output_key
662
+ case config.output
663
+ when Symbol, String then config.output
664
+ when OutputConfig then config.output.key
665
+ when Hash then config.output[:key]
666
+ end
667
+ end
668
+
669
+ def output_schema
670
+ case config.output
671
+ when OutputConfig then config.output.schema
672
+ when Hash then config.output[:schema]
673
+ end
674
+ end
675
+
676
+ def validate_output!(result)
677
+ SchemaValidator.validate!(
678
+ result,
679
+ output_schema,
680
+ context: "Step '#{step.id}' output"
681
+ )
682
+ end
683
+ end
684
+ end
685
+ end
686
+ end
687
+ ```
688
+
689
+ ## Key Features
690
+
691
+ 1. **Hook System** - Extensions register via `Parser.before_parse`, `Parser.after_parse`, `Parser.transform_config`
692
+ 2. **No Monkey Patching** - AI extension uses hooks, not `alias_method`
693
+ 3. **Schema'd Output** - `output: { key: order, schema: { ... } }` format supported
694
+ 4. **Variable Reachability** - Validates `$refs` are set by preceding steps
695
+ 5. **Schema Compatibility** - Validates `$order.name` against `order`'s schema
696
+ 6. **Runtime Validation** - Optional json_schemer validation at execution time
697
+
698
+ ## Example: Extension Registration
699
+
700
+ ```ruby
701
+ # In extensions/ai/ai.rb
702
+
703
+ DurableWorkflow::Core::Parser.after_parse do |workflow|
704
+ # Parse agents/tools from raw YAML and store in extensions
705
+ # This runs after core parsing, so we modify the WorkflowDef
706
+ workflow.with(extensions: workflow.extensions.merge(
707
+ agents: parsed_agents,
708
+ tools: parsed_tools
709
+ ))
710
+ end
711
+ ```
712
+
713
+ ## Acceptance Criteria
714
+
715
+ 1. `Parser.parse(yaml)` returns WorkflowDef
716
+ 2. `Parser.after_parse { |wf| ... }` hooks are called
717
+ 3. Variable references to undefined vars raise ValidationError
718
+ 4. Schema path validation catches `$order.nonexistent`
719
+ 5. Runtime schema validation works when json_schemer available