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