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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. 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