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,641 @@
|
|
|
1
|
+
# 01-STORAGE: Durable Storage Adapters
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement durable storage adapters: Redis, ActiveRecord, and Sequel. No Memory adapter - "durable" means persistent.
|
|
6
|
+
|
|
7
|
+
**Important**: Storage saves/loads `Execution` objects (not `State`). `Execution` has typed fields:
|
|
8
|
+
- `status` (Symbol enum: `:pending`, `:running`, `:completed`, `:halted`, `:failed`)
|
|
9
|
+
- `halt_data` (Hash, optional)
|
|
10
|
+
- `error` (String, optional)
|
|
11
|
+
- `recover_to` (String, optional - step to resume from)
|
|
12
|
+
- `result` (Any, optional - final output)
|
|
13
|
+
- `ctx` (Hash - clean user workflow variables only)
|
|
14
|
+
|
|
15
|
+
## Dependencies
|
|
16
|
+
|
|
17
|
+
- Phase 1 complete
|
|
18
|
+
|
|
19
|
+
## Files to Create
|
|
20
|
+
|
|
21
|
+
### 1. `lib/durable_workflow/storage/store.rb` (Interface)
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# frozen_string_literal: true
|
|
25
|
+
|
|
26
|
+
module DurableWorkflow
|
|
27
|
+
module Storage
|
|
28
|
+
class Store
|
|
29
|
+
# Save execution (typed Execution struct)
|
|
30
|
+
def save(execution)
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Load execution by ID, returns Execution or nil
|
|
35
|
+
def load(execution_id)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Record audit entry
|
|
40
|
+
def record(entry)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get entries for execution
|
|
45
|
+
def entries(execution_id)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Find executions by criteria
|
|
50
|
+
def find(workflow_id: nil, status: nil, limit: 100)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Delete execution and its entries
|
|
55
|
+
def delete(execution_id)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# List all execution IDs (for cleanup, admin)
|
|
60
|
+
def execution_ids(workflow_id: nil, limit: 1000)
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. `lib/durable_workflow/storage/redis.rb`
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# frozen_string_literal: true
|
|
72
|
+
|
|
73
|
+
require "json"
|
|
74
|
+
require "redis"
|
|
75
|
+
|
|
76
|
+
module DurableWorkflow
|
|
77
|
+
module Storage
|
|
78
|
+
class Redis < Store
|
|
79
|
+
PREFIX = "durable_workflow"
|
|
80
|
+
|
|
81
|
+
def initialize(redis: nil, url: nil, ttl: 86400 * 7)
|
|
82
|
+
@redis = redis || ::Redis.new(url:)
|
|
83
|
+
@ttl = ttl
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def save(execution)
|
|
87
|
+
key = exec_key(execution.id)
|
|
88
|
+
data = serialize_execution(execution)
|
|
89
|
+
@redis.setex(key, @ttl, data)
|
|
90
|
+
index_add(execution)
|
|
91
|
+
execution
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load(execution_id)
|
|
95
|
+
data = @redis.get(exec_key(execution_id))
|
|
96
|
+
data ? deserialize_execution(data) : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def record(entry)
|
|
100
|
+
key = entries_key(entry.execution_id)
|
|
101
|
+
data = serialize_entry(entry)
|
|
102
|
+
@redis.rpush(key, data)
|
|
103
|
+
@redis.expire(key, @ttl)
|
|
104
|
+
entry
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def entries(execution_id)
|
|
108
|
+
key = entries_key(execution_id)
|
|
109
|
+
@redis.lrange(key, 0, -1).map { deserialize_entry(_1) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find(workflow_id: nil, status: nil, limit: 100)
|
|
113
|
+
ids = if workflow_id
|
|
114
|
+
@redis.smembers(index_key(workflow_id)).first(limit)
|
|
115
|
+
else
|
|
116
|
+
scan_execution_ids(limit)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
results = ids.filter_map { load(_1) }
|
|
120
|
+
results = results.select { _1.status == status } if status
|
|
121
|
+
results.first(limit)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def delete(execution_id)
|
|
125
|
+
execution = load(execution_id)
|
|
126
|
+
return false unless execution
|
|
127
|
+
|
|
128
|
+
@redis.del(exec_key(execution_id))
|
|
129
|
+
@redis.del(entries_key(execution_id))
|
|
130
|
+
index_remove(execution)
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def execution_ids(workflow_id: nil, limit: 1000)
|
|
135
|
+
if workflow_id
|
|
136
|
+
@redis.smembers(index_key(workflow_id)).first(limit)
|
|
137
|
+
else
|
|
138
|
+
scan_execution_ids(limit)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def exec_key(id)
|
|
145
|
+
"#{PREFIX}:exec:#{id}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def entries_key(id)
|
|
149
|
+
"#{PREFIX}:entries:#{id}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def index_key(wf_id)
|
|
153
|
+
"#{PREFIX}:idx:#{wf_id}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def index_add(execution)
|
|
157
|
+
@redis.sadd(index_key(execution.workflow_id), execution.id)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def index_remove(execution)
|
|
161
|
+
@redis.srem(index_key(execution.workflow_id), execution.id)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def scan_execution_ids(limit)
|
|
165
|
+
ids = []
|
|
166
|
+
cursor = "0"
|
|
167
|
+
pattern = "#{PREFIX}:exec:*"
|
|
168
|
+
|
|
169
|
+
loop do
|
|
170
|
+
cursor, keys = @redis.scan(cursor, match: pattern, count: 100)
|
|
171
|
+
ids.concat(keys.map { _1.split(":").last })
|
|
172
|
+
break if cursor == "0" || ids.size >= limit
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
ids.first(limit)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def serialize_execution(execution)
|
|
179
|
+
JSON.generate(execution.to_h)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def deserialize_execution(json)
|
|
183
|
+
Core::Execution.from_h(symbolize(JSON.parse(json)))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def serialize_entry(entry)
|
|
187
|
+
JSON.generate(entry.to_h)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def deserialize_entry(json)
|
|
191
|
+
Core::Entry.from_h(symbolize(JSON.parse(json)))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def symbolize(obj)
|
|
195
|
+
case obj
|
|
196
|
+
when Hash then obj.transform_keys(&:to_sym).transform_values { symbolize(_1) }
|
|
197
|
+
when Array then obj.map { symbolize(_1) }
|
|
198
|
+
else obj
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 3. `lib/durable_workflow/storage/active_record.rb`
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# frozen_string_literal: true
|
|
210
|
+
|
|
211
|
+
require "json"
|
|
212
|
+
|
|
213
|
+
module DurableWorkflow
|
|
214
|
+
module Storage
|
|
215
|
+
class ActiveRecord < Store
|
|
216
|
+
# Assumes two tables exist:
|
|
217
|
+
# workflow_executions: id (uuid), workflow_id, status, input (json), ctx (json),
|
|
218
|
+
# current_step, result (json), recover_to, halt_data (json),
|
|
219
|
+
# error (text), created_at, updated_at
|
|
220
|
+
# workflow_entries: id (uuid), execution_id, step_id, step_type, action,
|
|
221
|
+
# duration_ms, input (json), output (json), error, timestamp
|
|
222
|
+
|
|
223
|
+
def initialize(execution_class:, entry_class:)
|
|
224
|
+
@execution_class = execution_class
|
|
225
|
+
@entry_class = entry_class
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def save(execution)
|
|
229
|
+
record = @execution_class.find_or_initialize_by(id: execution.id)
|
|
230
|
+
record.assign_attributes(
|
|
231
|
+
workflow_id: execution.workflow_id,
|
|
232
|
+
status: execution.status.to_s,
|
|
233
|
+
input: execution.input.to_json,
|
|
234
|
+
ctx: execution.ctx.to_json,
|
|
235
|
+
current_step: execution.current_step,
|
|
236
|
+
result: execution.result&.to_json,
|
|
237
|
+
recover_to: execution.recover_to,
|
|
238
|
+
halt_data: execution.halt_data&.to_json,
|
|
239
|
+
error: execution.error
|
|
240
|
+
)
|
|
241
|
+
record.save!
|
|
242
|
+
execution
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def load(execution_id)
|
|
246
|
+
record = @execution_class.find_by(id: execution_id)
|
|
247
|
+
return nil unless record
|
|
248
|
+
|
|
249
|
+
Core::Execution.new(
|
|
250
|
+
id: record.id,
|
|
251
|
+
workflow_id: record.workflow_id,
|
|
252
|
+
status: record.status.to_sym,
|
|
253
|
+
input: parse_json(record.input),
|
|
254
|
+
ctx: parse_json(record.ctx),
|
|
255
|
+
current_step: record.current_step,
|
|
256
|
+
result: parse_json(record.result),
|
|
257
|
+
recover_to: record.recover_to,
|
|
258
|
+
halt_data: parse_json(record.halt_data),
|
|
259
|
+
error: record.error,
|
|
260
|
+
created_at: record.created_at,
|
|
261
|
+
updated_at: record.updated_at
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def record(entry)
|
|
266
|
+
@entry_class.create!(
|
|
267
|
+
id: entry.id,
|
|
268
|
+
execution_id: entry.execution_id,
|
|
269
|
+
step_id: entry.step_id,
|
|
270
|
+
step_type: entry.step_type,
|
|
271
|
+
action: entry.action.to_s,
|
|
272
|
+
duration_ms: entry.duration_ms,
|
|
273
|
+
input: entry.input&.to_json,
|
|
274
|
+
output: entry.output&.to_json,
|
|
275
|
+
error: entry.error,
|
|
276
|
+
timestamp: entry.timestamp
|
|
277
|
+
)
|
|
278
|
+
entry
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def entries(execution_id)
|
|
282
|
+
@entry_class.where(execution_id:).order(:timestamp).map do |r|
|
|
283
|
+
Core::Entry.new(
|
|
284
|
+
id: r.id,
|
|
285
|
+
execution_id: r.execution_id,
|
|
286
|
+
step_id: r.step_id,
|
|
287
|
+
step_type: r.step_type,
|
|
288
|
+
action: r.action.to_sym,
|
|
289
|
+
duration_ms: r.duration_ms,
|
|
290
|
+
input: parse_json(r.input),
|
|
291
|
+
output: parse_json(r.output),
|
|
292
|
+
error: r.error,
|
|
293
|
+
timestamp: r.timestamp
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def find(workflow_id: nil, status: nil, limit: 100)
|
|
299
|
+
scope = @execution_class.all
|
|
300
|
+
scope = scope.where(workflow_id:) if workflow_id
|
|
301
|
+
scope = scope.where(status: status.to_s) if status
|
|
302
|
+
scope.limit(limit).order(created_at: :desc).map do |record|
|
|
303
|
+
Core::Execution.new(
|
|
304
|
+
id: record.id,
|
|
305
|
+
workflow_id: record.workflow_id,
|
|
306
|
+
status: record.status.to_sym,
|
|
307
|
+
input: parse_json(record.input),
|
|
308
|
+
ctx: parse_json(record.ctx),
|
|
309
|
+
current_step: record.current_step,
|
|
310
|
+
result: parse_json(record.result),
|
|
311
|
+
recover_to: record.recover_to,
|
|
312
|
+
halt_data: parse_json(record.halt_data),
|
|
313
|
+
error: record.error,
|
|
314
|
+
created_at: record.created_at,
|
|
315
|
+
updated_at: record.updated_at
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def delete(execution_id)
|
|
321
|
+
record = @execution_class.find_by(id: execution_id)
|
|
322
|
+
return false unless record
|
|
323
|
+
|
|
324
|
+
@entry_class.where(execution_id:).delete_all
|
|
325
|
+
record.destroy
|
|
326
|
+
true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def execution_ids(workflow_id: nil, limit: 1000)
|
|
330
|
+
scope = @execution_class.all
|
|
331
|
+
scope = scope.where(workflow_id:) if workflow_id
|
|
332
|
+
scope.limit(limit).pluck(:id)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def parse_json(str)
|
|
338
|
+
return nil if str.nil? || str.empty?
|
|
339
|
+
result = JSON.parse(str)
|
|
340
|
+
result.is_a?(Hash) ? DurableWorkflow::Utils.deep_symbolize(result) : result
|
|
341
|
+
rescue JSON::ParserError
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### 4. `lib/durable_workflow/storage/sequel.rb`
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
# frozen_string_literal: true
|
|
353
|
+
|
|
354
|
+
require "json"
|
|
355
|
+
require "sequel"
|
|
356
|
+
|
|
357
|
+
module DurableWorkflow
|
|
358
|
+
module Storage
|
|
359
|
+
class Sequel < Store
|
|
360
|
+
# Tables:
|
|
361
|
+
# workflow_executions: id (uuid pk), workflow_id, status, input (jsonb), ctx (jsonb),
|
|
362
|
+
# current_step, result (jsonb), recover_to, halt_data (jsonb),
|
|
363
|
+
# error (text), created_at, updated_at
|
|
364
|
+
# workflow_entries: id (uuid pk), execution_id (fk), step_id, step_type, action,
|
|
365
|
+
# duration_ms, input (jsonb), output (jsonb), error, timestamp
|
|
366
|
+
|
|
367
|
+
def initialize(db:, executions_table: :workflow_executions, entries_table: :workflow_entries)
|
|
368
|
+
@db = db
|
|
369
|
+
@executions = db[executions_table]
|
|
370
|
+
@entries = db[entries_table]
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def save(execution)
|
|
374
|
+
now = Time.now
|
|
375
|
+
data = {
|
|
376
|
+
workflow_id: execution.workflow_id,
|
|
377
|
+
status: execution.status.to_s,
|
|
378
|
+
input: ::Sequel.pg_jsonb(execution.input),
|
|
379
|
+
ctx: ::Sequel.pg_jsonb(execution.ctx),
|
|
380
|
+
current_step: execution.current_step,
|
|
381
|
+
result: execution.result ? ::Sequel.pg_jsonb(execution.result) : nil,
|
|
382
|
+
recover_to: execution.recover_to,
|
|
383
|
+
halt_data: execution.halt_data ? ::Sequel.pg_jsonb(execution.halt_data) : nil,
|
|
384
|
+
error: execution.error,
|
|
385
|
+
updated_at: now
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if @executions.where(id: execution.id).count > 0
|
|
389
|
+
@executions.where(id: execution.id).update(data)
|
|
390
|
+
else
|
|
391
|
+
@executions.insert(data.merge(id: execution.id, created_at: now))
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
execution
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def load(execution_id)
|
|
398
|
+
row = @executions.where(id: execution_id).first
|
|
399
|
+
return nil unless row
|
|
400
|
+
|
|
401
|
+
Core::Execution.new(
|
|
402
|
+
id: row[:id],
|
|
403
|
+
workflow_id: row[:workflow_id],
|
|
404
|
+
status: row[:status].to_sym,
|
|
405
|
+
input: symbolize(row[:input] || {}),
|
|
406
|
+
ctx: symbolize(row[:ctx] || {}),
|
|
407
|
+
current_step: row[:current_step],
|
|
408
|
+
result: symbolize(row[:result]),
|
|
409
|
+
recover_to: row[:recover_to],
|
|
410
|
+
halt_data: symbolize(row[:halt_data]),
|
|
411
|
+
error: row[:error],
|
|
412
|
+
created_at: row[:created_at],
|
|
413
|
+
updated_at: row[:updated_at]
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def record(entry)
|
|
418
|
+
@entries.insert(
|
|
419
|
+
id: entry.id,
|
|
420
|
+
execution_id: entry.execution_id,
|
|
421
|
+
step_id: entry.step_id,
|
|
422
|
+
step_type: entry.step_type,
|
|
423
|
+
action: entry.action.to_s,
|
|
424
|
+
duration_ms: entry.duration_ms,
|
|
425
|
+
input: entry.input ? ::Sequel.pg_jsonb(entry.input) : nil,
|
|
426
|
+
output: entry.output ? ::Sequel.pg_jsonb(entry.output) : nil,
|
|
427
|
+
error: entry.error,
|
|
428
|
+
timestamp: entry.timestamp
|
|
429
|
+
)
|
|
430
|
+
entry
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def entries(execution_id)
|
|
434
|
+
@entries.where(execution_id:).order(:timestamp).map do |row|
|
|
435
|
+
Core::Entry.new(
|
|
436
|
+
id: row[:id],
|
|
437
|
+
execution_id: row[:execution_id],
|
|
438
|
+
step_id: row[:step_id],
|
|
439
|
+
step_type: row[:step_type],
|
|
440
|
+
action: row[:action].to_sym,
|
|
441
|
+
duration_ms: row[:duration_ms],
|
|
442
|
+
input: symbolize(row[:input]),
|
|
443
|
+
output: symbolize(row[:output]),
|
|
444
|
+
error: row[:error],
|
|
445
|
+
timestamp: row[:timestamp]
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def find(workflow_id: nil, status: nil, limit: 100)
|
|
451
|
+
scope = @executions
|
|
452
|
+
scope = scope.where(workflow_id:) if workflow_id
|
|
453
|
+
scope = scope.where(status: status.to_s) if status
|
|
454
|
+
scope.order(::Sequel.desc(:created_at)).limit(limit).map do |row|
|
|
455
|
+
Core::Execution.new(
|
|
456
|
+
id: row[:id],
|
|
457
|
+
workflow_id: row[:workflow_id],
|
|
458
|
+
status: row[:status].to_sym,
|
|
459
|
+
input: symbolize(row[:input] || {}),
|
|
460
|
+
ctx: symbolize(row[:ctx] || {}),
|
|
461
|
+
current_step: row[:current_step],
|
|
462
|
+
result: symbolize(row[:result]),
|
|
463
|
+
recover_to: row[:recover_to],
|
|
464
|
+
halt_data: symbolize(row[:halt_data]),
|
|
465
|
+
error: row[:error],
|
|
466
|
+
created_at: row[:created_at],
|
|
467
|
+
updated_at: row[:updated_at]
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def delete(execution_id)
|
|
473
|
+
count = @executions.where(id: execution_id).delete
|
|
474
|
+
@entries.where(execution_id:).delete
|
|
475
|
+
count > 0
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def execution_ids(workflow_id: nil, limit: 1000)
|
|
479
|
+
scope = @executions
|
|
480
|
+
scope = scope.where(workflow_id:) if workflow_id
|
|
481
|
+
scope.limit(limit).select_map(:id)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
private
|
|
485
|
+
|
|
486
|
+
def symbolize(obj)
|
|
487
|
+
case obj
|
|
488
|
+
when Hash then obj.transform_keys(&:to_sym).transform_values { symbolize(_1) }
|
|
489
|
+
when Array then obj.map { symbolize(_1) }
|
|
490
|
+
else obj
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### 5. Migration Templates
|
|
499
|
+
|
|
500
|
+
#### For ActiveRecord: `db/migrate/XXX_create_workflow_tables.rb`
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
class CreateWorkflowTables < ActiveRecord::Migration[7.0]
|
|
504
|
+
def change
|
|
505
|
+
create_table :workflow_executions, id: false do |t|
|
|
506
|
+
t.uuid :id, primary_key: true, default: -> { "gen_random_uuid()" }
|
|
507
|
+
t.string :workflow_id, null: false
|
|
508
|
+
t.string :status, null: false, default: "running"
|
|
509
|
+
t.jsonb :input, default: {}
|
|
510
|
+
t.jsonb :ctx, default: {}
|
|
511
|
+
t.string :current_step
|
|
512
|
+
t.jsonb :result # Final output when completed
|
|
513
|
+
t.string :recover_to # Step to resume from
|
|
514
|
+
t.jsonb :halt_data # Data from HaltResult
|
|
515
|
+
t.text :error # Error message when failed
|
|
516
|
+
|
|
517
|
+
t.timestamps
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
add_index :workflow_executions, :workflow_id
|
|
521
|
+
add_index :workflow_executions, :status
|
|
522
|
+
|
|
523
|
+
create_table :workflow_entries, id: false do |t|
|
|
524
|
+
t.uuid :id, primary_key: true, default: -> { "gen_random_uuid()" }
|
|
525
|
+
t.uuid :execution_id, null: false
|
|
526
|
+
t.string :step_id, null: false
|
|
527
|
+
t.string :step_type, null: false
|
|
528
|
+
t.string :action, null: false
|
|
529
|
+
t.integer :duration_ms
|
|
530
|
+
t.jsonb :input
|
|
531
|
+
t.jsonb :output
|
|
532
|
+
t.text :error
|
|
533
|
+
t.datetime :timestamp, null: false
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
add_index :workflow_entries, :execution_id
|
|
537
|
+
add_foreign_key :workflow_entries, :workflow_executions, column: :execution_id
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
#### For Sequel:
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
Sequel.migration do
|
|
546
|
+
change do
|
|
547
|
+
create_table :workflow_executions do
|
|
548
|
+
column :id, :uuid, primary_key: true, default: Sequel.lit("gen_random_uuid()")
|
|
549
|
+
String :workflow_id, null: false
|
|
550
|
+
String :status, null: false, default: "running"
|
|
551
|
+
column :input, :jsonb, default: Sequel.pg_jsonb({})
|
|
552
|
+
column :ctx, :jsonb, default: Sequel.pg_jsonb({})
|
|
553
|
+
String :current_step
|
|
554
|
+
column :result, :jsonb # Final output when completed
|
|
555
|
+
String :recover_to # Step to resume from
|
|
556
|
+
column :halt_data, :jsonb # Data from HaltResult
|
|
557
|
+
String :error, text: true # Error message when failed
|
|
558
|
+
DateTime :created_at, null: false
|
|
559
|
+
DateTime :updated_at, null: false
|
|
560
|
+
|
|
561
|
+
index :workflow_id
|
|
562
|
+
index :status
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
create_table :workflow_entries do
|
|
566
|
+
column :id, :uuid, primary_key: true, default: Sequel.lit("gen_random_uuid()")
|
|
567
|
+
foreign_key :execution_id, :workflow_executions, type: :uuid, null: false
|
|
568
|
+
String :step_id, null: false
|
|
569
|
+
String :step_type, null: false
|
|
570
|
+
String :action, null: false
|
|
571
|
+
Integer :duration_ms
|
|
572
|
+
column :input, :jsonb
|
|
573
|
+
column :output, :jsonb
|
|
574
|
+
String :error, text: true
|
|
575
|
+
DateTime :timestamp, null: false
|
|
576
|
+
|
|
577
|
+
index :execution_id
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Usage
|
|
584
|
+
|
|
585
|
+
### Redis
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
require "durable_workflow"
|
|
589
|
+
require "durable_workflow/storage/redis"
|
|
590
|
+
|
|
591
|
+
DurableWorkflow.configure do |c|
|
|
592
|
+
c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
|
|
593
|
+
end
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### ActiveRecord
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
require "durable_workflow"
|
|
600
|
+
require "durable_workflow/storage/active_record"
|
|
601
|
+
|
|
602
|
+
# Define your models
|
|
603
|
+
class WorkflowExecution < ApplicationRecord
|
|
604
|
+
self.table_name = "workflow_executions"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
class WorkflowEntry < ApplicationRecord
|
|
608
|
+
self.table_name = "workflow_entries"
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
DurableWorkflow.configure do |c|
|
|
612
|
+
c.store = DurableWorkflow::Storage::ActiveRecord.new(
|
|
613
|
+
execution_class: WorkflowExecution,
|
|
614
|
+
entry_class: WorkflowEntry
|
|
615
|
+
)
|
|
616
|
+
end
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Sequel
|
|
620
|
+
|
|
621
|
+
```ruby
|
|
622
|
+
require "durable_workflow"
|
|
623
|
+
require "durable_workflow/storage/sequel"
|
|
624
|
+
|
|
625
|
+
DB = Sequel.connect("postgres://localhost/myapp")
|
|
626
|
+
|
|
627
|
+
DurableWorkflow.configure do |c|
|
|
628
|
+
c.store = DurableWorkflow::Storage::Sequel.new(db: DB)
|
|
629
|
+
end
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Acceptance Criteria
|
|
633
|
+
|
|
634
|
+
1. Redis adapter saves/loads Execution correctly (typed status, halt_data, error, recover_to)
|
|
635
|
+
2. ActiveRecord adapter works with standard Rails models
|
|
636
|
+
3. Sequel adapter works with Postgres JSONB
|
|
637
|
+
4. All adapters implement full Store interface
|
|
638
|
+
5. Entries are properly linked to executions
|
|
639
|
+
6. `find(status: :halted)` uses typed status field (not ctx[:_status])
|
|
640
|
+
7. `execution.to_state` conversion works for resume
|
|
641
|
+
8. No `ctx[:_status]`, `ctx[:_halt]`, `ctx[:_error]` - all in typed Execution fields
|