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,511 @@
|
|
|
1
|
+
# 02-RUNNERS: Sync, Async, and Stream Runners
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement execution runners: Sync (blocking), Async (background jobs), and Stream (SSE events).
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
- Phase 1 complete
|
|
10
|
+
- 01-STORAGE complete
|
|
11
|
+
|
|
12
|
+
## Files to Create
|
|
13
|
+
|
|
14
|
+
### 1. `lib/durable_workflow/runners/sync.rb`
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# frozen_string_literal: true
|
|
18
|
+
|
|
19
|
+
module DurableWorkflow
|
|
20
|
+
module Runners
|
|
21
|
+
class Sync
|
|
22
|
+
attr_reader :workflow, :store
|
|
23
|
+
|
|
24
|
+
def initialize(workflow, store: nil)
|
|
25
|
+
@workflow = workflow
|
|
26
|
+
@store = store || DurableWorkflow.config&.store
|
|
27
|
+
raise ConfigError, "No store configured" unless @store
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run workflow, block until complete/halted
|
|
31
|
+
def run(input, execution_id: nil)
|
|
32
|
+
engine = Core::Engine.new(workflow, store:)
|
|
33
|
+
engine.run(input, execution_id:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resume halted workflow
|
|
37
|
+
def resume(execution_id, response: nil, approved: nil)
|
|
38
|
+
engine = Core::Engine.new(workflow, store:)
|
|
39
|
+
engine.resume(execution_id, response:, approved:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run until fully complete (auto-handle halts with block)
|
|
43
|
+
# Without block, returns halted result when halt encountered
|
|
44
|
+
def run_until_complete(input, execution_id: nil)
|
|
45
|
+
result = run(input, execution_id:)
|
|
46
|
+
|
|
47
|
+
while result.halted? && block_given?
|
|
48
|
+
response = yield result.halt
|
|
49
|
+
result = resume(result.execution_id, response:)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. `lib/durable_workflow/runners/async.rb`
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# frozen_string_literal: true
|
|
63
|
+
|
|
64
|
+
module DurableWorkflow
|
|
65
|
+
module Runners
|
|
66
|
+
class Async
|
|
67
|
+
attr_reader :workflow, :store, :adapter
|
|
68
|
+
|
|
69
|
+
def initialize(workflow, store: nil, adapter: nil)
|
|
70
|
+
@workflow = workflow
|
|
71
|
+
@store = store || DurableWorkflow.config&.store
|
|
72
|
+
raise ConfigError, "No store configured" unless @store
|
|
73
|
+
@adapter = adapter || Adapters::Inline.new(store: @store)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Queue workflow for execution, return immediately
|
|
77
|
+
def run(input, execution_id: nil, queue: nil, priority: nil)
|
|
78
|
+
exec_id = execution_id || SecureRandom.uuid
|
|
79
|
+
|
|
80
|
+
# Pre-create Execution with :pending status
|
|
81
|
+
execution = Core::Execution.new(
|
|
82
|
+
id: exec_id,
|
|
83
|
+
workflow_id: workflow.id,
|
|
84
|
+
status: :pending,
|
|
85
|
+
input: input.freeze,
|
|
86
|
+
ctx: {}
|
|
87
|
+
)
|
|
88
|
+
store.save(execution)
|
|
89
|
+
|
|
90
|
+
# Enqueue
|
|
91
|
+
adapter.enqueue(
|
|
92
|
+
workflow_id: workflow.id,
|
|
93
|
+
workflow_data: serialize_workflow,
|
|
94
|
+
execution_id: exec_id,
|
|
95
|
+
input:,
|
|
96
|
+
action: :start,
|
|
97
|
+
queue:,
|
|
98
|
+
priority:
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
exec_id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Queue resume
|
|
105
|
+
def resume(execution_id, response: nil, approved: nil, queue: nil)
|
|
106
|
+
adapter.enqueue(
|
|
107
|
+
workflow_id: workflow.id,
|
|
108
|
+
workflow_data: serialize_workflow,
|
|
109
|
+
execution_id:,
|
|
110
|
+
response:,
|
|
111
|
+
approved:,
|
|
112
|
+
action: :resume,
|
|
113
|
+
queue:
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
execution_id
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Poll for completion
|
|
120
|
+
def wait(execution_id, timeout: 30, interval: 0.1)
|
|
121
|
+
deadline = Time.now + timeout
|
|
122
|
+
|
|
123
|
+
while Time.now < deadline
|
|
124
|
+
execution = store.load(execution_id)
|
|
125
|
+
|
|
126
|
+
case execution&.status
|
|
127
|
+
when :completed, :failed, :halted
|
|
128
|
+
return build_result(execution)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
sleep(interval)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
nil # Timeout
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get current status
|
|
138
|
+
def status(execution_id)
|
|
139
|
+
execution = store.load(execution_id)
|
|
140
|
+
execution&.status || :unknown
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def serialize_workflow
|
|
146
|
+
{ id: workflow.id, name: workflow.name, version: workflow.version }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_result(execution)
|
|
150
|
+
Core::ExecutionResult.new(
|
|
151
|
+
status: execution.status,
|
|
152
|
+
execution_id: execution.id,
|
|
153
|
+
output: execution.result,
|
|
154
|
+
halt: execution.status == :halted ? Core::HaltResult.new(data: execution.halt_data || {}) : nil,
|
|
155
|
+
error: execution.error
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 3. `lib/durable_workflow/runners/stream.rb`
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# frozen_string_literal: true
|
|
167
|
+
|
|
168
|
+
require "json"
|
|
169
|
+
|
|
170
|
+
module DurableWorkflow
|
|
171
|
+
module Runners
|
|
172
|
+
# Stream event - typed struct for SSE events
|
|
173
|
+
class Event < BaseStruct
|
|
174
|
+
attribute :type, Types::Strict::String
|
|
175
|
+
attribute :data, Types::Hash.default({}.freeze)
|
|
176
|
+
attribute :timestamp, Types::Any
|
|
177
|
+
|
|
178
|
+
def to_h
|
|
179
|
+
{ type:, data:, timestamp: timestamp.is_a?(Time) ? timestamp.iso8601 : timestamp }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def to_json(*)
|
|
183
|
+
JSON.generate(to_h)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def to_sse
|
|
187
|
+
"event: #{type}\ndata: #{to_json}\n\n"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class Stream
|
|
192
|
+
EVENTS = %w[
|
|
193
|
+
workflow.started workflow.completed workflow.halted workflow.failed
|
|
194
|
+
step.started step.completed step.failed step.halted
|
|
195
|
+
].freeze
|
|
196
|
+
|
|
197
|
+
attr_reader :workflow, :store, :subscribers
|
|
198
|
+
|
|
199
|
+
def initialize(workflow, store: nil)
|
|
200
|
+
@workflow = workflow
|
|
201
|
+
@store = store || DurableWorkflow.config&.store
|
|
202
|
+
raise ConfigError, "No store configured" unless @store
|
|
203
|
+
@subscribers = []
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Subscribe to events
|
|
207
|
+
def subscribe(events: nil, &block)
|
|
208
|
+
@subscribers << { events:, handler: block }
|
|
209
|
+
self
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Run with event streaming
|
|
213
|
+
def run(input, execution_id: nil)
|
|
214
|
+
emit("workflow.started", workflow_id: workflow.id, input:)
|
|
215
|
+
|
|
216
|
+
engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
|
|
217
|
+
result = engine.run(input, execution_id:)
|
|
218
|
+
|
|
219
|
+
case result.status
|
|
220
|
+
when :completed
|
|
221
|
+
emit("workflow.completed", execution_id: result.execution_id, output: result.output)
|
|
222
|
+
when :halted
|
|
223
|
+
emit("workflow.halted", execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
|
|
224
|
+
when :failed
|
|
225
|
+
emit("workflow.failed", execution_id: result.execution_id, error: result.error)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
result
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Resume with event streaming
|
|
232
|
+
def resume(execution_id, response: nil, approved: nil)
|
|
233
|
+
emit("workflow.resumed", execution_id:)
|
|
234
|
+
|
|
235
|
+
engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
|
|
236
|
+
result = engine.resume(execution_id, response:, approved:)
|
|
237
|
+
|
|
238
|
+
case result.status
|
|
239
|
+
when :completed
|
|
240
|
+
emit("workflow.completed", execution_id: result.execution_id, output: result.output)
|
|
241
|
+
when :halted
|
|
242
|
+
emit("workflow.halted", execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
|
|
243
|
+
when :failed
|
|
244
|
+
emit("workflow.failed", execution_id: result.execution_id, error: result.error)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
result
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Emit event
|
|
251
|
+
def emit(type, **data)
|
|
252
|
+
event = Event.new(type:, data:, timestamp: Time.now)
|
|
253
|
+
|
|
254
|
+
subscribers.each do |sub|
|
|
255
|
+
next if sub[:events] && !sub[:events].include?(type)
|
|
256
|
+
sub[:handler].call(event)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Engine subclass with event hooks
|
|
262
|
+
class StreamingEngine < Core::Engine
|
|
263
|
+
def initialize(workflow, store:, emitter:)
|
|
264
|
+
super(workflow, store:)
|
|
265
|
+
@emitter = emitter
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def execute_step(state, step)
|
|
271
|
+
@emitter.call("step.started", step_id: step.id, step_type: step.type)
|
|
272
|
+
|
|
273
|
+
outcome = super
|
|
274
|
+
|
|
275
|
+
event = case outcome.result
|
|
276
|
+
when Core::HaltResult then "step.halted"
|
|
277
|
+
else "step.completed"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
@emitter.call(event, step_id: step.id, output: outcome.result.output)
|
|
281
|
+
|
|
282
|
+
outcome
|
|
283
|
+
rescue => e
|
|
284
|
+
@emitter.call("step.failed", step_id: step.id, error: e.message)
|
|
285
|
+
raise
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### 4. `lib/durable_workflow/runners/adapters/inline.rb`
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
# frozen_string_literal: true
|
|
296
|
+
|
|
297
|
+
module DurableWorkflow
|
|
298
|
+
module Runners
|
|
299
|
+
module Adapters
|
|
300
|
+
class Inline
|
|
301
|
+
def initialize(store: nil)
|
|
302
|
+
@store = store
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def enqueue(workflow_id:, workflow_data:, execution_id:, action:, **kwargs)
|
|
306
|
+
# Execute immediately in current thread (for testing/dev)
|
|
307
|
+
perform(
|
|
308
|
+
workflow_id:,
|
|
309
|
+
workflow_data:,
|
|
310
|
+
execution_id:,
|
|
311
|
+
action:,
|
|
312
|
+
**kwargs
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def perform(workflow_id:, workflow_data:, execution_id:, action:, input: nil, response: nil, approved: nil, **_)
|
|
317
|
+
workflow = DurableWorkflow.registry[workflow_id]
|
|
318
|
+
raise ExecutionError, "Workflow not found: #{workflow_id}" unless workflow
|
|
319
|
+
|
|
320
|
+
store = @store || DurableWorkflow.config&.store
|
|
321
|
+
raise ConfigError, "No store configured" unless store
|
|
322
|
+
|
|
323
|
+
engine = Core::Engine.new(workflow, store:)
|
|
324
|
+
|
|
325
|
+
# Engine saves Execution with proper typed status - no manual status update needed
|
|
326
|
+
case action.to_sym
|
|
327
|
+
when :start
|
|
328
|
+
engine.run(input || {}, execution_id:)
|
|
329
|
+
when :resume
|
|
330
|
+
engine.resume(execution_id, response:, approved:)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### 5. `lib/durable_workflow/runners/adapters/sidekiq.rb`
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# frozen_string_literal: true
|
|
343
|
+
|
|
344
|
+
module DurableWorkflow
|
|
345
|
+
module Runners
|
|
346
|
+
module Adapters
|
|
347
|
+
class Sidekiq
|
|
348
|
+
def initialize(job_class: nil)
|
|
349
|
+
@job_class = job_class || default_job_class
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def enqueue(workflow_id:, workflow_data:, execution_id:, action:, queue: nil, priority: nil, **kwargs)
|
|
353
|
+
job_args = {
|
|
354
|
+
workflow_id:,
|
|
355
|
+
workflow_data:,
|
|
356
|
+
execution_id:,
|
|
357
|
+
action: action.to_s,
|
|
358
|
+
**kwargs.compact
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if queue
|
|
362
|
+
@job_class.set(queue:).perform_async(job_args)
|
|
363
|
+
else
|
|
364
|
+
@job_class.perform_async(job_args)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
execution_id
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
private
|
|
371
|
+
|
|
372
|
+
def default_job_class
|
|
373
|
+
# Define a default job class if sidekiq is available
|
|
374
|
+
return @default_job_class if defined?(@default_job_class)
|
|
375
|
+
|
|
376
|
+
@default_job_class = Class.new do
|
|
377
|
+
if defined?(::Sidekiq::Job)
|
|
378
|
+
include ::Sidekiq::Job
|
|
379
|
+
|
|
380
|
+
def perform(args)
|
|
381
|
+
args = DurableWorkflow::Utils.deep_symbolize(args)
|
|
382
|
+
|
|
383
|
+
workflow = DurableWorkflow.registry[args[:workflow_id]]
|
|
384
|
+
raise DurableWorkflow::ExecutionError, "Workflow not found: #{args[:workflow_id]}" unless workflow
|
|
385
|
+
|
|
386
|
+
store = DurableWorkflow.config&.store
|
|
387
|
+
raise DurableWorkflow::ConfigError, "No store configured" unless store
|
|
388
|
+
|
|
389
|
+
engine = DurableWorkflow::Core::Engine.new(workflow, store:)
|
|
390
|
+
|
|
391
|
+
# Engine saves Execution with proper typed status - no manual status update needed
|
|
392
|
+
case args[:action].to_sym
|
|
393
|
+
when :start
|
|
394
|
+
engine.run(args[:input] || {}, execution_id: args[:execution_id])
|
|
395
|
+
when :resume
|
|
396
|
+
engine.resume(args[:execution_id], response: args[:response], approved: args[:approved])
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Register in Object so it can be found by Sidekiq
|
|
403
|
+
Object.const_set(:DurableWorkflowJob, @default_job_class) unless defined?(::DurableWorkflowJob)
|
|
404
|
+
|
|
405
|
+
@default_job_class
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### 6. Update `lib/durable_workflow.rb` (require runners)
|
|
414
|
+
|
|
415
|
+
Add to the main entry point:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
# Runners
|
|
419
|
+
require_relative "durable_workflow/runners/sync"
|
|
420
|
+
require_relative "durable_workflow/runners/async"
|
|
421
|
+
require_relative "durable_workflow/runners/stream"
|
|
422
|
+
require_relative "durable_workflow/runners/adapters/inline"
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Usage Examples
|
|
426
|
+
|
|
427
|
+
### Sync Runner
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
wf = DurableWorkflow.load("order.yml")
|
|
431
|
+
runner = DurableWorkflow::Runners::Sync.new(wf)
|
|
432
|
+
|
|
433
|
+
# Simple run
|
|
434
|
+
result = runner.run(user_id: 123, items: [...])
|
|
435
|
+
puts result.status # :completed, :halted, or :failed
|
|
436
|
+
|
|
437
|
+
# Run with approval handling
|
|
438
|
+
result = runner.run_until_complete(user_id: 123) do |halt|
|
|
439
|
+
puts "Approval needed: #{halt.prompt}"
|
|
440
|
+
# Return user response
|
|
441
|
+
{ approved: true }
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Async Runner
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
wf = DurableWorkflow.load("order.yml")
|
|
449
|
+
DurableWorkflow.register(wf) # Required for async
|
|
450
|
+
|
|
451
|
+
runner = DurableWorkflow::Runners::Async.new(wf)
|
|
452
|
+
|
|
453
|
+
# Fire and forget
|
|
454
|
+
exec_id = runner.run(user_id: 123)
|
|
455
|
+
|
|
456
|
+
# Poll for result
|
|
457
|
+
result = runner.wait(exec_id, timeout: 60)
|
|
458
|
+
|
|
459
|
+
# Check status
|
|
460
|
+
status = runner.status(exec_id) # :pending, :running, :completed, :halted, :failed
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Stream Runner (SSE)
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
wf = DurableWorkflow.load("order.yml")
|
|
467
|
+
runner = DurableWorkflow::Runners::Stream.new(wf)
|
|
468
|
+
|
|
469
|
+
# Subscribe to events
|
|
470
|
+
runner.subscribe do |event|
|
|
471
|
+
puts event.to_sse
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Or subscribe to specific events
|
|
475
|
+
runner.subscribe(events: ["step.completed", "workflow.completed"]) do |event|
|
|
476
|
+
broadcast_to_client(event.to_json)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Run with streaming
|
|
480
|
+
result = runner.run(user_id: 123)
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Sidekiq Adapter
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
require "durable_workflow/runners/adapters/sidekiq"
|
|
487
|
+
|
|
488
|
+
wf = DurableWorkflow.load("order.yml")
|
|
489
|
+
DurableWorkflow.register(wf)
|
|
490
|
+
|
|
491
|
+
runner = DurableWorkflow::Runners::Async.new(
|
|
492
|
+
wf,
|
|
493
|
+
adapter: DurableWorkflow::Runners::Adapters::Sidekiq.new
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
exec_id = runner.run(user_id: 123, queue: "workflows")
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## Acceptance Criteria
|
|
500
|
+
|
|
501
|
+
1. Sync runner blocks until completion
|
|
502
|
+
2. Async runner returns immediately with execution_id
|
|
503
|
+
3. Stream runner emits events for each step
|
|
504
|
+
4. Sidekiq adapter enqueues jobs correctly
|
|
505
|
+
5. All runners require store configuration (no fallback to memory)
|
|
506
|
+
6. `run_until_complete` handles approval loops
|
|
507
|
+
7. `Event` uses `BaseStruct` (not Ruby Struct)
|
|
508
|
+
8. Async runner creates `Execution` with typed `status: :pending` (not `ctx[:_status]`)
|
|
509
|
+
9. Async `wait`/`status` uses `execution.status` (not `ctx[:_status]`)
|
|
510
|
+
10. Async `build_result` uses `execution.result`, `execution.halt_data`, `execution.error`
|
|
511
|
+
11. Adapters don't manually update status - Engine handles it via `Execution`
|