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