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