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,744 @@
|
|
|
1
|
+
# 02-EXAMPLES-SIMPLE: Single-File Example Scripts
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Simple, runnable Ruby scripts demonstrating core workflow features. Each is self-contained.
|
|
6
|
+
|
|
7
|
+
## Directory Structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
examples/
|
|
11
|
+
hello_workflow.rb # Simplest possible workflow
|
|
12
|
+
calculator.rb # Routing + service calls
|
|
13
|
+
item_processor.rb # Service-based data processing
|
|
14
|
+
approval_request.rb # Halt and resume
|
|
15
|
+
parallel_fetch.rb # Concurrent execution
|
|
16
|
+
service_integration.rb # External service calls
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Key Constraints
|
|
22
|
+
|
|
23
|
+
**IMPORTANT**: The resolver only supports `$ref` substitution. There is NO Ruby expression evaluation.
|
|
24
|
+
|
|
25
|
+
- ✅ `$input.name` - reference substitution
|
|
26
|
+
- ✅ `$result.value` - nested reference
|
|
27
|
+
- ✅ `"Hello, $input.name!"` - string interpolation
|
|
28
|
+
- ❌ `$a + $b` - no arithmetic
|
|
29
|
+
- ❌ `$items.length` - no method calls
|
|
30
|
+
- ❌ `$total * 0.08` - no expressions
|
|
31
|
+
|
|
32
|
+
**For any computation, use a service via the `call` step.**
|
|
33
|
+
|
|
34
|
+
Other constraints:
|
|
35
|
+
- Services resolved via `Object.const_get(name)` - must be globally accessible Ruby constants
|
|
36
|
+
- `runner.run({ key: value })` - pass hash, not keyword args
|
|
37
|
+
- Router `field:` does NOT include `$` prefix (evaluator adds it internally)
|
|
38
|
+
- Call step uses `input:` not `args:`
|
|
39
|
+
- Parallel branches is an array of step definitions, not a named hash
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 1. `examples/hello_workflow.rb`
|
|
44
|
+
|
|
45
|
+
**Demonstrates:** Basic workflow structure, assign step, input/output, `$ref` substitution
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
#!/usr/bin/env ruby
|
|
49
|
+
# frozen_string_literal: true
|
|
50
|
+
|
|
51
|
+
# Hello Workflow - Simplest possible durable workflow
|
|
52
|
+
#
|
|
53
|
+
# Run: ruby examples/hello_workflow.rb
|
|
54
|
+
# Requires: Redis running on localhost:6379
|
|
55
|
+
|
|
56
|
+
require "bundler/setup"
|
|
57
|
+
require "durable_workflow"
|
|
58
|
+
require "durable_workflow/storage/redis"
|
|
59
|
+
|
|
60
|
+
# Configure storage
|
|
61
|
+
DurableWorkflow.configure do |c|
|
|
62
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Define workflow inline
|
|
66
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
67
|
+
id: hello_world
|
|
68
|
+
name: Hello World
|
|
69
|
+
version: "1.0"
|
|
70
|
+
|
|
71
|
+
inputs:
|
|
72
|
+
name:
|
|
73
|
+
type: string
|
|
74
|
+
required: true
|
|
75
|
+
|
|
76
|
+
steps:
|
|
77
|
+
- id: start
|
|
78
|
+
type: start
|
|
79
|
+
next: greet
|
|
80
|
+
|
|
81
|
+
- id: greet
|
|
82
|
+
type: assign
|
|
83
|
+
set:
|
|
84
|
+
greeting: "Hello, $input.name!"
|
|
85
|
+
timestamp: "$now"
|
|
86
|
+
next: end
|
|
87
|
+
|
|
88
|
+
- id: end
|
|
89
|
+
type: end
|
|
90
|
+
result:
|
|
91
|
+
message: "$greeting"
|
|
92
|
+
generated_at: "$timestamp"
|
|
93
|
+
YAML
|
|
94
|
+
|
|
95
|
+
# Run it (pass input as a hash, not kwargs)
|
|
96
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
97
|
+
result = runner.run({ name: "World" })
|
|
98
|
+
|
|
99
|
+
puts "Status: #{result.status}"
|
|
100
|
+
puts "Output: #{result.output}"
|
|
101
|
+
# => Status: completed
|
|
102
|
+
# => Output: {:message=>"Hello, World!", :generated_at=>2024-...}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 2. `examples/calculator.rb`
|
|
108
|
+
|
|
109
|
+
**Demonstrates:** Router step, conditional branching, service calls for computation
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
#!/usr/bin/env ruby
|
|
113
|
+
# frozen_string_literal: true
|
|
114
|
+
|
|
115
|
+
# Calculator Workflow - Routing based on input
|
|
116
|
+
#
|
|
117
|
+
# Run: ruby examples/calculator.rb
|
|
118
|
+
# Requires: Redis running on localhost:6379
|
|
119
|
+
|
|
120
|
+
require "bundler/setup"
|
|
121
|
+
require "durable_workflow"
|
|
122
|
+
require "durable_workflow/storage/redis"
|
|
123
|
+
|
|
124
|
+
# Calculator service - computation happens in Ruby code
|
|
125
|
+
# Must be globally accessible (module at top level)
|
|
126
|
+
module Calculator
|
|
127
|
+
def self.add(a:, b:)
|
|
128
|
+
{ result: a + b, operation: "addition" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.subtract(a:, b:)
|
|
132
|
+
{ result: a - b, operation: "subtraction" }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.multiply(a:, b:)
|
|
136
|
+
{ result: a * b, operation: "multiplication" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.divide(a:, b:)
|
|
140
|
+
{ result: a.to_f / b, operation: "division" }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
DurableWorkflow.configure do |c|
|
|
145
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
149
|
+
id: calculator
|
|
150
|
+
name: Calculator
|
|
151
|
+
version: "1.0"
|
|
152
|
+
|
|
153
|
+
inputs:
|
|
154
|
+
operation:
|
|
155
|
+
type: string
|
|
156
|
+
required: true
|
|
157
|
+
a:
|
|
158
|
+
type: number
|
|
159
|
+
required: true
|
|
160
|
+
b:
|
|
161
|
+
type: number
|
|
162
|
+
required: true
|
|
163
|
+
|
|
164
|
+
steps:
|
|
165
|
+
- id: start
|
|
166
|
+
type: start
|
|
167
|
+
next: route
|
|
168
|
+
|
|
169
|
+
- id: route
|
|
170
|
+
type: router
|
|
171
|
+
routes:
|
|
172
|
+
- when:
|
|
173
|
+
field: input.operation
|
|
174
|
+
op: eq
|
|
175
|
+
value: "add"
|
|
176
|
+
then: add
|
|
177
|
+
- when:
|
|
178
|
+
field: input.operation
|
|
179
|
+
op: eq
|
|
180
|
+
value: "subtract"
|
|
181
|
+
then: subtract
|
|
182
|
+
- when:
|
|
183
|
+
field: input.operation
|
|
184
|
+
op: eq
|
|
185
|
+
value: "multiply"
|
|
186
|
+
then: multiply
|
|
187
|
+
- when:
|
|
188
|
+
field: input.operation
|
|
189
|
+
op: eq
|
|
190
|
+
value: "divide"
|
|
191
|
+
then: divide
|
|
192
|
+
default: error
|
|
193
|
+
|
|
194
|
+
- id: add
|
|
195
|
+
type: call
|
|
196
|
+
service: Calculator
|
|
197
|
+
method: add
|
|
198
|
+
input:
|
|
199
|
+
a: "$input.a"
|
|
200
|
+
b: "$input.b"
|
|
201
|
+
output: calc_result
|
|
202
|
+
next: end
|
|
203
|
+
|
|
204
|
+
- id: subtract
|
|
205
|
+
type: call
|
|
206
|
+
service: Calculator
|
|
207
|
+
method: subtract
|
|
208
|
+
input:
|
|
209
|
+
a: "$input.a"
|
|
210
|
+
b: "$input.b"
|
|
211
|
+
output: calc_result
|
|
212
|
+
next: end
|
|
213
|
+
|
|
214
|
+
- id: multiply
|
|
215
|
+
type: call
|
|
216
|
+
service: Calculator
|
|
217
|
+
method: multiply
|
|
218
|
+
input:
|
|
219
|
+
a: "$input.a"
|
|
220
|
+
b: "$input.b"
|
|
221
|
+
output: calc_result
|
|
222
|
+
next: end
|
|
223
|
+
|
|
224
|
+
- id: divide
|
|
225
|
+
type: call
|
|
226
|
+
service: Calculator
|
|
227
|
+
method: divide
|
|
228
|
+
input:
|
|
229
|
+
a: "$input.a"
|
|
230
|
+
b: "$input.b"
|
|
231
|
+
output: calc_result
|
|
232
|
+
next: end
|
|
233
|
+
|
|
234
|
+
- id: error
|
|
235
|
+
type: assign
|
|
236
|
+
set:
|
|
237
|
+
calc_result:
|
|
238
|
+
error: "Unknown operation"
|
|
239
|
+
next: end
|
|
240
|
+
|
|
241
|
+
- id: end
|
|
242
|
+
type: end
|
|
243
|
+
result:
|
|
244
|
+
result: "$calc_result.result"
|
|
245
|
+
operation: "$calc_result.operation"
|
|
246
|
+
error: "$calc_result.error"
|
|
247
|
+
YAML
|
|
248
|
+
|
|
249
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
250
|
+
|
|
251
|
+
# Test all operations
|
|
252
|
+
[
|
|
253
|
+
{ operation: "add", a: 10, b: 5 },
|
|
254
|
+
{ operation: "subtract", a: 10, b: 5 },
|
|
255
|
+
{ operation: "multiply", a: 10, b: 5 },
|
|
256
|
+
{ operation: "divide", a: 10, b: 5 }
|
|
257
|
+
].each do |input|
|
|
258
|
+
result = runner.run(input)
|
|
259
|
+
puts "#{input[:a]} #{input[:operation]} #{input[:b]} = #{result.output[:result]}"
|
|
260
|
+
end
|
|
261
|
+
# => 10 add 5 = 15
|
|
262
|
+
# => 10 subtract 5 = 5
|
|
263
|
+
# => 10 multiply 5 = 50
|
|
264
|
+
# => 10 divide 5 = 2.0
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 3. `examples/item_processor.rb`
|
|
270
|
+
|
|
271
|
+
**Demonstrates:** Service-based data processing
|
|
272
|
+
|
|
273
|
+
Note: The workflow engine has a `loop` step type, but since we can't do arithmetic in YAML,
|
|
274
|
+
we delegate all processing to a service and keep the workflow simple.
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
#!/usr/bin/env ruby
|
|
278
|
+
# frozen_string_literal: true
|
|
279
|
+
|
|
280
|
+
# Item Processor - Process collection via service
|
|
281
|
+
#
|
|
282
|
+
# Run: ruby examples/item_processor.rb
|
|
283
|
+
# Requires: Redis running on localhost:6379
|
|
284
|
+
|
|
285
|
+
require "bundler/setup"
|
|
286
|
+
require "durable_workflow"
|
|
287
|
+
require "durable_workflow/storage/redis"
|
|
288
|
+
|
|
289
|
+
# Service for item processing (must be globally accessible)
|
|
290
|
+
module ItemProcessor
|
|
291
|
+
def self.process(items:)
|
|
292
|
+
total = 0
|
|
293
|
+
processed = []
|
|
294
|
+
|
|
295
|
+
items.each do |item|
|
|
296
|
+
line_total = item[:quantity] * item[:price]
|
|
297
|
+
total += line_total
|
|
298
|
+
processed << { name: item[:name], subtotal: line_total }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
{
|
|
302
|
+
count: items.size,
|
|
303
|
+
total: total,
|
|
304
|
+
average: items.empty? ? 0 : total.to_f / items.size,
|
|
305
|
+
items: processed
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
DurableWorkflow.configure do |c|
|
|
311
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
315
|
+
id: item_processor
|
|
316
|
+
name: Item Processor
|
|
317
|
+
version: "1.0"
|
|
318
|
+
|
|
319
|
+
inputs:
|
|
320
|
+
items:
|
|
321
|
+
type: array
|
|
322
|
+
required: true
|
|
323
|
+
|
|
324
|
+
steps:
|
|
325
|
+
- id: start
|
|
326
|
+
type: start
|
|
327
|
+
next: process
|
|
328
|
+
|
|
329
|
+
- id: process
|
|
330
|
+
type: call
|
|
331
|
+
service: ItemProcessor
|
|
332
|
+
method: process
|
|
333
|
+
input:
|
|
334
|
+
items: "$input.items"
|
|
335
|
+
output: result
|
|
336
|
+
next: end
|
|
337
|
+
|
|
338
|
+
- id: end
|
|
339
|
+
type: end
|
|
340
|
+
result:
|
|
341
|
+
item_count: "$result.count"
|
|
342
|
+
total: "$result.total"
|
|
343
|
+
average: "$result.average"
|
|
344
|
+
items: "$result.items"
|
|
345
|
+
YAML
|
|
346
|
+
|
|
347
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
348
|
+
|
|
349
|
+
result = runner.run({
|
|
350
|
+
items: [
|
|
351
|
+
{ name: "Widget", quantity: 3, price: 10.00 },
|
|
352
|
+
{ name: "Gadget", quantity: 2, price: 25.00 },
|
|
353
|
+
{ name: "Gizmo", quantity: 5, price: 5.00 }
|
|
354
|
+
]
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
puts "Processed #{result.output[:item_count]} items"
|
|
358
|
+
puts "Total: $#{result.output[:total]}"
|
|
359
|
+
puts "Average: $#{result.output[:average].round(2)}"
|
|
360
|
+
puts "Breakdown:"
|
|
361
|
+
result.output[:items].each do |item|
|
|
362
|
+
puts " #{item[:name]}: $#{item[:subtotal]}"
|
|
363
|
+
end
|
|
364
|
+
# => Processed 3 items
|
|
365
|
+
# => Total: $105.0
|
|
366
|
+
# => Average: $35.0
|
|
367
|
+
# => Breakdown:
|
|
368
|
+
# => Widget: $30.0
|
|
369
|
+
# => Gadget: $50.0
|
|
370
|
+
# => Gizmo: $25.0
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## 4. `examples/approval_request.rb`
|
|
376
|
+
|
|
377
|
+
**Demonstrates:** Approval step, halt and resume, human-in-the-loop
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
#!/usr/bin/env ruby
|
|
381
|
+
# frozen_string_literal: true
|
|
382
|
+
|
|
383
|
+
# Approval Request - Workflow that halts for human input
|
|
384
|
+
#
|
|
385
|
+
# Run: ruby examples/approval_request.rb
|
|
386
|
+
# Requires: Redis running on localhost:6379
|
|
387
|
+
|
|
388
|
+
require "bundler/setup"
|
|
389
|
+
require "durable_workflow"
|
|
390
|
+
require "durable_workflow/storage/redis"
|
|
391
|
+
|
|
392
|
+
DurableWorkflow.configure do |c|
|
|
393
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
397
|
+
id: expense_approval
|
|
398
|
+
name: Expense Approval
|
|
399
|
+
version: "1.0"
|
|
400
|
+
|
|
401
|
+
inputs:
|
|
402
|
+
requester:
|
|
403
|
+
type: string
|
|
404
|
+
required: true
|
|
405
|
+
amount:
|
|
406
|
+
type: number
|
|
407
|
+
required: true
|
|
408
|
+
description:
|
|
409
|
+
type: string
|
|
410
|
+
required: true
|
|
411
|
+
|
|
412
|
+
steps:
|
|
413
|
+
- id: start
|
|
414
|
+
type: start
|
|
415
|
+
next: check_amount
|
|
416
|
+
|
|
417
|
+
- id: check_amount
|
|
418
|
+
type: router
|
|
419
|
+
routes:
|
|
420
|
+
- when:
|
|
421
|
+
field: input.amount
|
|
422
|
+
op: gt
|
|
423
|
+
value: 100
|
|
424
|
+
then: require_approval
|
|
425
|
+
default: auto_approve
|
|
426
|
+
|
|
427
|
+
- id: auto_approve
|
|
428
|
+
type: assign
|
|
429
|
+
set:
|
|
430
|
+
approved: true
|
|
431
|
+
approved_by: system
|
|
432
|
+
reason: "Amount under threshold"
|
|
433
|
+
next: end
|
|
434
|
+
|
|
435
|
+
- id: require_approval
|
|
436
|
+
type: approval
|
|
437
|
+
prompt: "Please approve expense request"
|
|
438
|
+
context:
|
|
439
|
+
requester: "$input.requester"
|
|
440
|
+
amount: "$input.amount"
|
|
441
|
+
description: "$input.description"
|
|
442
|
+
on_reject: rejected
|
|
443
|
+
next: approved
|
|
444
|
+
|
|
445
|
+
- id: approved
|
|
446
|
+
type: assign
|
|
447
|
+
set:
|
|
448
|
+
approved: true
|
|
449
|
+
approved_by: manager
|
|
450
|
+
next: end
|
|
451
|
+
|
|
452
|
+
- id: rejected
|
|
453
|
+
type: assign
|
|
454
|
+
set:
|
|
455
|
+
approved: false
|
|
456
|
+
approved_by: manager
|
|
457
|
+
reason: "Request rejected"
|
|
458
|
+
next: end
|
|
459
|
+
|
|
460
|
+
- id: end
|
|
461
|
+
type: end
|
|
462
|
+
result:
|
|
463
|
+
approved: "$approved"
|
|
464
|
+
approved_by: "$approved_by"
|
|
465
|
+
reason: "$reason"
|
|
466
|
+
YAML
|
|
467
|
+
|
|
468
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
469
|
+
|
|
470
|
+
# Small expense - auto-approved
|
|
471
|
+
result = runner.run({ requester: "Alice", amount: 50, description: "Office supplies" })
|
|
472
|
+
puts "Small expense: #{result.output}"
|
|
473
|
+
# => Small expense: {:approved=>true, :approved_by=>"system", :reason=>"Amount under threshold"}
|
|
474
|
+
|
|
475
|
+
# Large expense - requires approval (halts)
|
|
476
|
+
result = runner.run({ requester: "Bob", amount: 500, description: "Conference ticket" })
|
|
477
|
+
puts "\nLarge expense halted: #{result.status}"
|
|
478
|
+
puts "Halt data: #{result.halt&.data}"
|
|
479
|
+
# Workflow halts here - would resume with approved: true/false
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## 5. `examples/parallel_fetch.rb`
|
|
485
|
+
|
|
486
|
+
**Demonstrates:** Parallel step, concurrent execution
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
#!/usr/bin/env ruby
|
|
490
|
+
# frozen_string_literal: true
|
|
491
|
+
|
|
492
|
+
# Parallel Fetch - Execute multiple operations concurrently
|
|
493
|
+
#
|
|
494
|
+
# Run: ruby examples/parallel_fetch.rb
|
|
495
|
+
# Requires: Redis running on localhost:6379, async gem
|
|
496
|
+
|
|
497
|
+
require "bundler/setup"
|
|
498
|
+
require "durable_workflow"
|
|
499
|
+
require "durable_workflow/storage/redis"
|
|
500
|
+
|
|
501
|
+
DurableWorkflow.configure do |c|
|
|
502
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Mock services (must be globally accessible constants)
|
|
506
|
+
module UserService
|
|
507
|
+
def self.get_profile(user_id:)
|
|
508
|
+
sleep(0.1) # Simulate latency
|
|
509
|
+
{ id: user_id, name: "User #{user_id}", email: "user#{user_id}@example.com" }
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
module OrderService
|
|
514
|
+
def self.get_recent(user_id:, limit:)
|
|
515
|
+
sleep(0.1)
|
|
516
|
+
limit.times.map { |i| { id: "ORD-#{i}", amount: rand(10..100) } }
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
module NotificationService
|
|
521
|
+
def self.get_unread(user_id:)
|
|
522
|
+
sleep(0.1)
|
|
523
|
+
rand(0..5).times.map { |i| { id: "NOTIF-#{i}", message: "Notification #{i}" } }
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
528
|
+
id: dashboard_data
|
|
529
|
+
name: Dashboard Data Fetch
|
|
530
|
+
version: "1.0"
|
|
531
|
+
|
|
532
|
+
inputs:
|
|
533
|
+
user_id:
|
|
534
|
+
type: string
|
|
535
|
+
required: true
|
|
536
|
+
|
|
537
|
+
steps:
|
|
538
|
+
- id: start
|
|
539
|
+
type: start
|
|
540
|
+
next: fetch_all
|
|
541
|
+
|
|
542
|
+
- id: fetch_all
|
|
543
|
+
type: parallel
|
|
544
|
+
branches:
|
|
545
|
+
- id: get_profile
|
|
546
|
+
type: call
|
|
547
|
+
service: UserService
|
|
548
|
+
method: get_profile
|
|
549
|
+
input:
|
|
550
|
+
user_id: "$input.user_id"
|
|
551
|
+
output: profile
|
|
552
|
+
|
|
553
|
+
- id: get_orders
|
|
554
|
+
type: call
|
|
555
|
+
service: OrderService
|
|
556
|
+
method: get_recent
|
|
557
|
+
input:
|
|
558
|
+
user_id: "$input.user_id"
|
|
559
|
+
limit: 5
|
|
560
|
+
output: orders
|
|
561
|
+
|
|
562
|
+
- id: get_notifications
|
|
563
|
+
type: call
|
|
564
|
+
service: NotificationService
|
|
565
|
+
method: get_unread
|
|
566
|
+
input:
|
|
567
|
+
user_id: "$input.user_id"
|
|
568
|
+
output: notifications
|
|
569
|
+
next: end
|
|
570
|
+
|
|
571
|
+
- id: end
|
|
572
|
+
type: end
|
|
573
|
+
result:
|
|
574
|
+
user: "$profile"
|
|
575
|
+
recent_orders: "$orders"
|
|
576
|
+
notifications: "$notifications"
|
|
577
|
+
YAML
|
|
578
|
+
|
|
579
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
580
|
+
|
|
581
|
+
start_time = Time.now
|
|
582
|
+
result = runner.run({ user_id: "USER-123" })
|
|
583
|
+
elapsed = Time.now - start_time
|
|
584
|
+
|
|
585
|
+
puts "Fetched dashboard data in #{elapsed.round(2)}s (parallel, not sequential 0.3s)"
|
|
586
|
+
puts "User: #{result.output[:user][:name]}"
|
|
587
|
+
puts "Orders: #{result.output[:recent_orders].size}"
|
|
588
|
+
puts "Notifications: #{result.output[:notifications].size}"
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## 6. `examples/service_integration.rb`
|
|
594
|
+
|
|
595
|
+
**Demonstrates:** Call step, service resolution via Object.const_get, routing
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
#!/usr/bin/env ruby
|
|
599
|
+
# frozen_string_literal: true
|
|
600
|
+
|
|
601
|
+
# Service Integration - Calling external services from workflow
|
|
602
|
+
#
|
|
603
|
+
# Run: ruby examples/service_integration.rb
|
|
604
|
+
# Requires: Redis running on localhost:6379
|
|
605
|
+
|
|
606
|
+
require "bundler/setup"
|
|
607
|
+
require "securerandom"
|
|
608
|
+
require "durable_workflow"
|
|
609
|
+
require "durable_workflow/storage/redis"
|
|
610
|
+
|
|
611
|
+
# Inventory service (must be globally accessible constant)
|
|
612
|
+
module InventoryService
|
|
613
|
+
STOCK = {
|
|
614
|
+
"PROD-001" => 50,
|
|
615
|
+
"PROD-002" => 0,
|
|
616
|
+
"PROD-003" => 10
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
def self.check_availability(product_id:, quantity:)
|
|
620
|
+
available = STOCK.fetch(product_id, 0)
|
|
621
|
+
{
|
|
622
|
+
product_id: product_id,
|
|
623
|
+
requested: quantity,
|
|
624
|
+
available: available,
|
|
625
|
+
in_stock: available >= quantity
|
|
626
|
+
}
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def self.reserve(product_id:, quantity:)
|
|
630
|
+
current = STOCK.fetch(product_id, 0)
|
|
631
|
+
raise "Insufficient stock" if current < quantity
|
|
632
|
+
|
|
633
|
+
STOCK[product_id] = current - quantity
|
|
634
|
+
{
|
|
635
|
+
reservation_id: "RES-#{SecureRandom.hex(4)}",
|
|
636
|
+
product_id: product_id,
|
|
637
|
+
quantity: quantity,
|
|
638
|
+
remaining: STOCK[product_id]
|
|
639
|
+
}
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
DurableWorkflow.configure do |c|
|
|
644
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
|
|
648
|
+
id: inventory_check
|
|
649
|
+
name: Inventory Check and Reserve
|
|
650
|
+
version: "1.0"
|
|
651
|
+
|
|
652
|
+
inputs:
|
|
653
|
+
product_id:
|
|
654
|
+
type: string
|
|
655
|
+
required: true
|
|
656
|
+
quantity:
|
|
657
|
+
type: integer
|
|
658
|
+
required: true
|
|
659
|
+
|
|
660
|
+
steps:
|
|
661
|
+
- id: start
|
|
662
|
+
type: start
|
|
663
|
+
next: check
|
|
664
|
+
|
|
665
|
+
- id: check
|
|
666
|
+
type: call
|
|
667
|
+
service: InventoryService
|
|
668
|
+
method: check_availability
|
|
669
|
+
input:
|
|
670
|
+
product_id: "$input.product_id"
|
|
671
|
+
quantity: "$input.quantity"
|
|
672
|
+
output: availability
|
|
673
|
+
next: decide
|
|
674
|
+
|
|
675
|
+
- id: decide
|
|
676
|
+
type: router
|
|
677
|
+
routes:
|
|
678
|
+
- when:
|
|
679
|
+
field: availability.in_stock
|
|
680
|
+
op: eq
|
|
681
|
+
value: true
|
|
682
|
+
then: reserve
|
|
683
|
+
default: out_of_stock
|
|
684
|
+
|
|
685
|
+
- id: reserve
|
|
686
|
+
type: call
|
|
687
|
+
service: InventoryService
|
|
688
|
+
method: reserve
|
|
689
|
+
input:
|
|
690
|
+
product_id: "$input.product_id"
|
|
691
|
+
quantity: "$input.quantity"
|
|
692
|
+
output: reservation
|
|
693
|
+
next: success
|
|
694
|
+
|
|
695
|
+
- id: success
|
|
696
|
+
type: assign
|
|
697
|
+
set:
|
|
698
|
+
status: reserved
|
|
699
|
+
next: end
|
|
700
|
+
|
|
701
|
+
- id: out_of_stock
|
|
702
|
+
type: assign
|
|
703
|
+
set:
|
|
704
|
+
status: out_of_stock
|
|
705
|
+
error: "Insufficient stock available"
|
|
706
|
+
next: end
|
|
707
|
+
|
|
708
|
+
- id: end
|
|
709
|
+
type: end
|
|
710
|
+
result:
|
|
711
|
+
status: "$status"
|
|
712
|
+
availability: "$availability"
|
|
713
|
+
reservation: "$reservation"
|
|
714
|
+
error: "$error"
|
|
715
|
+
YAML
|
|
716
|
+
|
|
717
|
+
runner = DurableWorkflow::Runners::Sync.new(workflow)
|
|
718
|
+
|
|
719
|
+
# Available product
|
|
720
|
+
result = runner.run({ product_id: "PROD-001", quantity: 5 })
|
|
721
|
+
puts "PROD-001 (qty 5): #{result.output[:status]}"
|
|
722
|
+
puts " Reservation: #{result.output[:reservation][:reservation_id]}" if result.output[:reservation]
|
|
723
|
+
|
|
724
|
+
# Out of stock
|
|
725
|
+
result = runner.run({ product_id: "PROD-002", quantity: 1 })
|
|
726
|
+
puts "\nPROD-002 (qty 1): #{result.output[:status]}"
|
|
727
|
+
puts " Error: #{result.output[:error]}"
|
|
728
|
+
|
|
729
|
+
# Partial availability
|
|
730
|
+
result = runner.run({ product_id: "PROD-003", quantity: 20 })
|
|
731
|
+
puts "\nPROD-003 (qty 20): #{result.output[:status]}"
|
|
732
|
+
puts " Error: #{result.output[:error]}"
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## Acceptance Criteria
|
|
738
|
+
|
|
739
|
+
1. Each script is self-contained and runnable
|
|
740
|
+
2. Each demonstrates a single core concept
|
|
741
|
+
3. Output shows expected results
|
|
742
|
+
4. Comments explain what's being demonstrated
|
|
743
|
+
5. Uses realistic (not toy) examples
|
|
744
|
+
6. All examples pass when run with `ruby examples/<name>.rb`
|