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,2378 @@
1
+ # 01-TESTS: Minitest Test Suite
2
+
3
+ ## Goal
4
+
5
+ Complete test coverage using Minitest for all components: types, executors, engine, parser, storage, runners, and extensions.
6
+
7
+ ## Dependencies
8
+
9
+ - Phase 1 complete
10
+ - Phase 2 complete
11
+ - Phase 3 complete
12
+
13
+ ## Test Structure
14
+
15
+ ```
16
+ test/
17
+ ├── test_helper.rb
18
+ ├── core/
19
+ │ ├── types_test.rb
20
+ │ ├── state_test.rb
21
+ │ ├── engine_test.rb
22
+ │ ├── registry_test.rb
23
+ │ ├── resolver_test.rb
24
+ │ ├── condition_test.rb
25
+ │ ├── validator_test.rb
26
+ │ └── executors/
27
+ │ ├── start_test.rb
28
+ │ ├── end_test.rb
29
+ │ ├── call_test.rb
30
+ │ ├── assign_test.rb
31
+ │ ├── router_test.rb
32
+ │ ├── loop_test.rb
33
+ │ ├── halt_test.rb
34
+ │ ├── approval_test.rb
35
+ │ ├── transform_test.rb
36
+ │ ├── parallel_test.rb
37
+ │ └── workflow_test.rb
38
+ ├── parser_test.rb
39
+ ├── storage/
40
+ │ ├── redis_test.rb
41
+ │ ├── active_record_test.rb
42
+ │ └── sequel_test.rb
43
+ ├── runners/
44
+ │ ├── sync_test.rb
45
+ │ ├── async_test.rb
46
+ │ └── stream_test.rb
47
+ └── extensions/
48
+ ├── base_test.rb
49
+ └── ai/
50
+ ├── extension_test.rb
51
+ ├── agent_executor_test.rb
52
+ └── tool_executor_test.rb
53
+ ```
54
+
55
+ ## Files to Create
56
+
57
+ ### 1. `test/test_helper.rb`
58
+
59
+ ```ruby
60
+ # frozen_string_literal: true
61
+
62
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
63
+
64
+ require "minitest/autorun"
65
+ require "minitest/pride"
66
+ require "mocha/minitest"
67
+
68
+ require "durable_workflow"
69
+
70
+ # Test-only storage adapter (in-memory)
71
+ # NOTE: This is NOT shipped with the gem - it's only in test_helper.rb
72
+ # Production code must use Redis, ActiveRecord, or Sequel
73
+ module DurableWorkflow
74
+ module Storage
75
+ class Memory < Store
76
+ def initialize
77
+ @states = {}
78
+ @entries = {}
79
+ end
80
+
81
+ def save(state)
82
+ @states[state.execution_id] = state
83
+ state
84
+ end
85
+
86
+ def load(execution_id)
87
+ @states[execution_id]
88
+ end
89
+
90
+ def record(entry)
91
+ @entries[entry.execution_id] ||= []
92
+ @entries[entry.execution_id] << entry
93
+ entry
94
+ end
95
+
96
+ def entries(execution_id)
97
+ @entries[execution_id] || []
98
+ end
99
+
100
+ def find(workflow_id: nil, status: nil, limit: 100)
101
+ results = @states.values
102
+ results = results.select { _1.workflow_id == workflow_id } if workflow_id
103
+ results = results.select { _1.ctx[:_status] == status } if status
104
+ results.first(limit)
105
+ end
106
+
107
+ def delete(execution_id)
108
+ deleted = @states.delete(execution_id)
109
+ @entries.delete(execution_id)
110
+ !!deleted
111
+ end
112
+
113
+ def execution_ids(workflow_id: nil, limit: 1000)
114
+ ids = @states.keys
115
+ ids = ids.select { @states[_1].workflow_id == workflow_id } if workflow_id
116
+ ids.first(limit)
117
+ end
118
+
119
+ def clear!
120
+ @states.clear
121
+ @entries.clear
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ # Test fixtures
128
+ module TestFixtures
129
+ def simple_workflow_yaml
130
+ <<~YAML
131
+ id: test_workflow
132
+ name: Test Workflow
133
+ version: "1.0"
134
+ input_schema:
135
+ type: object
136
+ properties:
137
+ value:
138
+ type: integer
139
+ steps:
140
+ - id: start
141
+ type: start
142
+ next: process
143
+ - id: process
144
+ type: assign
145
+ config:
146
+ assignments:
147
+ result: "$.input.value * 2"
148
+ next: done
149
+ - id: done
150
+ type: end
151
+ YAML
152
+ end
153
+
154
+ def router_workflow_yaml
155
+ <<~YAML
156
+ id: router_test
157
+ name: Router Test
158
+ version: "1.0"
159
+ steps:
160
+ - id: start
161
+ type: start
162
+ next: route
163
+ - id: route
164
+ type: router
165
+ config:
166
+ routes:
167
+ - condition: "$.input.path == 'a'"
168
+ next: path_a
169
+ - condition: "$.input.path == 'b'"
170
+ next: path_b
171
+ default: path_default
172
+ - id: path_a
173
+ type: assign
174
+ config:
175
+ assignments:
176
+ result: "'went_a'"
177
+ next: done
178
+ - id: path_b
179
+ type: assign
180
+ config:
181
+ assignments:
182
+ result: "'went_b'"
183
+ next: done
184
+ - id: path_default
185
+ type: assign
186
+ config:
187
+ assignments:
188
+ result: "'went_default'"
189
+ next: done
190
+ - id: done
191
+ type: end
192
+ YAML
193
+ end
194
+
195
+ def loop_workflow_yaml
196
+ <<~YAML
197
+ id: loop_test
198
+ name: Loop Test
199
+ version: "1.0"
200
+ steps:
201
+ - id: start
202
+ type: start
203
+ next: init
204
+ - id: init
205
+ type: assign
206
+ config:
207
+ assignments:
208
+ counter: 0
209
+ sum: 0
210
+ next: loop
211
+ - id: loop
212
+ type: loop
213
+ config:
214
+ collection: "$.input.items"
215
+ item_var: item
216
+ body:
217
+ - id: add
218
+ type: assign
219
+ config:
220
+ assignments:
221
+ counter: "$.ctx.counter + 1"
222
+ sum: "$.ctx.sum + $.ctx.item"
223
+ next: done
224
+ - id: done
225
+ type: end
226
+ YAML
227
+ end
228
+
229
+ def halt_workflow_yaml
230
+ <<~YAML
231
+ id: halt_test
232
+ name: Halt Test
233
+ version: "1.0"
234
+ steps:
235
+ - id: start
236
+ type: start
237
+ next: halting
238
+ - id: halting
239
+ type: halt
240
+ config:
241
+ data:
242
+ message: "Waiting for input"
243
+ next: after_halt
244
+ - id: after_halt
245
+ type: assign
246
+ config:
247
+ assignments:
248
+ result: "$.ctx._response"
249
+ next: done
250
+ - id: done
251
+ type: end
252
+ YAML
253
+ end
254
+
255
+ def approval_workflow_yaml
256
+ <<~YAML
257
+ id: approval_test
258
+ name: Approval Test
259
+ version: "1.0"
260
+ steps:
261
+ - id: start
262
+ type: start
263
+ next: approve
264
+ - id: approve
265
+ type: approval
266
+ config:
267
+ prompt: "Do you approve?"
268
+ approved_next: approved_path
269
+ rejected_next: rejected_path
270
+ - id: approved_path
271
+ type: assign
272
+ config:
273
+ assignments:
274
+ result: "'approved'"
275
+ next: done
276
+ - id: rejected_path
277
+ type: assign
278
+ config:
279
+ assignments:
280
+ result: "'rejected'"
281
+ next: done
282
+ - id: done
283
+ type: end
284
+ YAML
285
+ end
286
+
287
+ def parallel_workflow_yaml
288
+ <<~YAML
289
+ id: parallel_test
290
+ name: Parallel Test
291
+ version: "1.0"
292
+ steps:
293
+ - id: start
294
+ type: start
295
+ next: parallel
296
+ - id: parallel
297
+ type: parallel
298
+ config:
299
+ branches:
300
+ branch_a:
301
+ - id: a1
302
+ type: assign
303
+ config:
304
+ assignments:
305
+ a_result: "'from_a'"
306
+ branch_b:
307
+ - id: b1
308
+ type: assign
309
+ config:
310
+ assignments:
311
+ b_result: "'from_b'"
312
+ merge_strategy: all
313
+ next: done
314
+ - id: done
315
+ type: end
316
+ YAML
317
+ end
318
+
319
+ def call_workflow_yaml
320
+ <<~YAML
321
+ id: call_test
322
+ name: Call Test
323
+ version: "1.0"
324
+ steps:
325
+ - id: start
326
+ type: start
327
+ next: call_service
328
+ - id: call_service
329
+ type: call
330
+ config:
331
+ service: test_service
332
+ method: echo
333
+ args:
334
+ message: "$.input.message"
335
+ next: done
336
+ - id: done
337
+ type: end
338
+ YAML
339
+ end
340
+
341
+ def transform_workflow_yaml
342
+ <<~YAML
343
+ id: transform_test
344
+ name: Transform Test
345
+ version: "1.0"
346
+ steps:
347
+ - id: start
348
+ type: start
349
+ next: transform
350
+ - id: transform
351
+ type: transform
352
+ config:
353
+ source: "$.input.data"
354
+ template:
355
+ name: "$.item.first_name + ' ' + $.item.last_name"
356
+ email: "$.item.email"
357
+ next: done
358
+ - id: done
359
+ type: end
360
+ YAML
361
+ end
362
+
363
+ def workflow_step_yaml
364
+ <<~YAML
365
+ id: parent_workflow
366
+ name: Parent Workflow
367
+ version: "1.0"
368
+ steps:
369
+ - id: start
370
+ type: start
371
+ next: sub
372
+ - id: sub
373
+ type: workflow
374
+ config:
375
+ workflow_id: child_workflow
376
+ input:
377
+ value: "$.input.value"
378
+ next: done
379
+ - id: done
380
+ type: end
381
+ YAML
382
+ end
383
+
384
+ def child_workflow_yaml
385
+ <<~YAML
386
+ id: child_workflow
387
+ name: Child Workflow
388
+ version: "1.0"
389
+ steps:
390
+ - id: start
391
+ type: start
392
+ next: double
393
+ - id: double
394
+ type: assign
395
+ config:
396
+ assignments:
397
+ result: "$.input.value * 2"
398
+ next: done
399
+ - id: done
400
+ type: end
401
+ YAML
402
+ end
403
+ end
404
+
405
+ class DurableWorkflowTest < Minitest::Test
406
+ include TestFixtures
407
+
408
+ def setup
409
+ @store = DurableWorkflow::Storage::Memory.new
410
+ DurableWorkflow.configure do |c|
411
+ c.store = @store
412
+ end
413
+ DurableWorkflow.registry.clear if DurableWorkflow.registry.respond_to?(:clear)
414
+ end
415
+
416
+ def teardown
417
+ @store.clear!
418
+ end
419
+ end
420
+ ```
421
+
422
+ ### 2. `test/core/types_test.rb`
423
+
424
+ ```ruby
425
+ # frozen_string_literal: true
426
+
427
+ require "test_helper"
428
+
429
+ class TypesTest < DurableWorkflowTest
430
+ def test_state_creation
431
+ state = DurableWorkflow::Core::State.new(
432
+ execution_id: "exec-1",
433
+ workflow_id: "wf-1",
434
+ input: { value: 42 }
435
+ )
436
+
437
+ assert_equal "exec-1", state.execution_id
438
+ assert_equal "wf-1", state.workflow_id
439
+ assert_equal({ value: 42 }, state.input)
440
+ assert_equal({}, state.ctx)
441
+ assert_nil state.current_step
442
+ assert_equal [], state.history
443
+ end
444
+
445
+ def test_state_with_ctx_immutable
446
+ state = DurableWorkflow::Core::State.new(
447
+ execution_id: "exec-1",
448
+ workflow_id: "wf-1",
449
+ input: {}
450
+ )
451
+
452
+ new_state = state.with_ctx(foo: "bar")
453
+
454
+ refute_same state, new_state
455
+ assert_equal({}, state.ctx)
456
+ assert_equal({ foo: "bar" }, new_state.ctx)
457
+ end
458
+
459
+ def test_state_with_ctx_merges
460
+ state = DurableWorkflow::Core::State.new(
461
+ execution_id: "exec-1",
462
+ workflow_id: "wf-1",
463
+ input: {},
464
+ ctx: { existing: "value" }
465
+ )
466
+
467
+ new_state = state.with_ctx(new_key: "new_value")
468
+
469
+ assert_equal({ existing: "value", new_key: "new_value" }, new_state.ctx)
470
+ end
471
+
472
+ def test_step_def_creation
473
+ step = DurableWorkflow::Core::StepDef.new(
474
+ id: "my_step",
475
+ type: "assign",
476
+ config: { assignments: { x: 1 } },
477
+ next_step: "next_one"
478
+ )
479
+
480
+ assert_equal "my_step", step.id
481
+ assert_equal "assign", step.type
482
+ assert_equal({ assignments: { x: 1 } }, step.config)
483
+ assert_equal "next_one", step.next_step
484
+ end
485
+
486
+ def test_workflow_def_creation
487
+ wf = DurableWorkflow::Core::WorkflowDef.new(
488
+ id: "my_workflow",
489
+ name: "My Workflow",
490
+ version: "1.0",
491
+ steps: []
492
+ )
493
+
494
+ assert_equal "my_workflow", wf.id
495
+ assert_equal "My Workflow", wf.name
496
+ assert_equal "1.0", wf.version
497
+ assert_equal [], wf.steps
498
+ assert_equal({}, wf.extensions)
499
+ end
500
+
501
+ def test_workflow_def_with_extensions
502
+ wf = DurableWorkflow::Core::WorkflowDef.new(
503
+ id: "my_workflow",
504
+ name: "My Workflow",
505
+ version: "1.0",
506
+ steps: [],
507
+ extensions: { ai: { agents: {} } }
508
+ )
509
+
510
+ assert_equal({ ai: { agents: {} } }, wf.extensions)
511
+ end
512
+
513
+ def test_step_result_creation
514
+ result = DurableWorkflow::Core::StepResult.new(output: { value: 42 })
515
+
516
+ assert_equal({ value: 42 }, result.output)
517
+ refute result.halted?
518
+ end
519
+
520
+ def test_halt_result_creation
521
+ halt = DurableWorkflow::Core::HaltResult.new(
522
+ data: { reason: "waiting" },
523
+ prompt: "Please provide input"
524
+ )
525
+
526
+ assert_equal({ reason: "waiting" }, halt.data)
527
+ assert_equal "Please provide input", halt.prompt
528
+ assert halt.halted?
529
+ end
530
+
531
+ def test_step_outcome_continue
532
+ outcome = DurableWorkflow::Core::StepOutcome.continue(
533
+ state: DurableWorkflow::Core::State.new(
534
+ execution_id: "e1",
535
+ workflow_id: "w1",
536
+ input: {}
537
+ ),
538
+ result: DurableWorkflow::Core::StepResult.new(output: {}),
539
+ next_step: "next"
540
+ )
541
+
542
+ assert_equal "next", outcome.next_step
543
+ refute outcome.halted?
544
+ refute outcome.terminal?
545
+ end
546
+
547
+ def test_step_outcome_halt
548
+ outcome = DurableWorkflow::Core::StepOutcome.halt(
549
+ state: DurableWorkflow::Core::State.new(
550
+ execution_id: "e1",
551
+ workflow_id: "w1",
552
+ input: {}
553
+ ),
554
+ result: DurableWorkflow::Core::HaltResult.new(data: {})
555
+ )
556
+
557
+ assert outcome.halted?
558
+ refute outcome.terminal?
559
+ end
560
+
561
+ def test_step_outcome_terminal
562
+ outcome = DurableWorkflow::Core::StepOutcome.terminal(
563
+ state: DurableWorkflow::Core::State.new(
564
+ execution_id: "e1",
565
+ workflow_id: "w1",
566
+ input: {}
567
+ ),
568
+ result: DurableWorkflow::Core::StepResult.new(output: { final: true })
569
+ )
570
+
571
+ assert outcome.terminal?
572
+ refute outcome.halted?
573
+ assert_nil outcome.next_step
574
+ end
575
+
576
+ def test_execution_result_completed
577
+ result = DurableWorkflow::Core::ExecutionResult.new(
578
+ status: :completed,
579
+ execution_id: "exec-1",
580
+ output: { value: 42 }
581
+ )
582
+
583
+ assert result.completed?
584
+ refute result.halted?
585
+ refute result.failed?
586
+ assert_equal({ value: 42 }, result.output)
587
+ end
588
+
589
+ def test_execution_result_halted
590
+ result = DurableWorkflow::Core::ExecutionResult.new(
591
+ status: :halted,
592
+ execution_id: "exec-1",
593
+ halt: DurableWorkflow::Core::HaltResult.new(data: { waiting: true })
594
+ )
595
+
596
+ assert result.halted?
597
+ refute result.completed?
598
+ assert_equal({ waiting: true }, result.halt.data)
599
+ end
600
+
601
+ def test_execution_result_failed
602
+ result = DurableWorkflow::Core::ExecutionResult.new(
603
+ status: :failed,
604
+ execution_id: "exec-1",
605
+ error: "Something went wrong"
606
+ )
607
+
608
+ assert result.failed?
609
+ assert_equal "Something went wrong", result.error
610
+ end
611
+
612
+ def test_entry_creation
613
+ entry = DurableWorkflow::Core::Entry.new(
614
+ id: "entry-1",
615
+ execution_id: "exec-1",
616
+ step_id: "step-1",
617
+ step_type: "assign",
618
+ action: :execute,
619
+ duration_ms: 100,
620
+ input: { a: 1 },
621
+ output: { b: 2 },
622
+ timestamp: Time.now
623
+ )
624
+
625
+ assert_equal "entry-1", entry.id
626
+ assert_equal :execute, entry.action
627
+ assert_equal 100, entry.duration_ms
628
+ end
629
+ end
630
+ ```
631
+
632
+ ### 3. `test/core/state_test.rb`
633
+
634
+ ```ruby
635
+ # frozen_string_literal: true
636
+
637
+ require "test_helper"
638
+
639
+ class StateTest < DurableWorkflowTest
640
+ def test_state_to_h
641
+ state = DurableWorkflow::Core::State.new(
642
+ execution_id: "exec-1",
643
+ workflow_id: "wf-1",
644
+ input: { value: 42 },
645
+ ctx: { result: 84 },
646
+ current_step: "process",
647
+ history: ["start"]
648
+ )
649
+
650
+ h = state.to_h
651
+
652
+ assert_equal "exec-1", h[:execution_id]
653
+ assert_equal "wf-1", h[:workflow_id]
654
+ assert_equal({ value: 42 }, h[:input])
655
+ assert_equal({ result: 84 }, h[:ctx])
656
+ assert_equal "process", h[:current_step]
657
+ assert_equal ["start"], h[:history]
658
+ end
659
+
660
+ def test_state_from_h
661
+ h = {
662
+ execution_id: "exec-1",
663
+ workflow_id: "wf-1",
664
+ input: { value: 42 },
665
+ ctx: { result: 84 },
666
+ current_step: "process",
667
+ history: ["start"]
668
+ }
669
+
670
+ state = DurableWorkflow::Core::State.from_h(h)
671
+
672
+ assert_equal "exec-1", state.execution_id
673
+ assert_equal({ value: 42 }, state.input)
674
+ assert_equal({ result: 84 }, state.ctx)
675
+ end
676
+
677
+ def test_state_move_to
678
+ state = DurableWorkflow::Core::State.new(
679
+ execution_id: "exec-1",
680
+ workflow_id: "wf-1",
681
+ input: {},
682
+ current_step: "step1"
683
+ )
684
+
685
+ new_state = state.move_to("step2")
686
+
687
+ refute_same state, new_state
688
+ assert_equal "step1", state.current_step
689
+ assert_equal "step2", new_state.current_step
690
+ assert_includes new_state.history, "step1"
691
+ end
692
+
693
+ def test_state_add_history
694
+ state = DurableWorkflow::Core::State.new(
695
+ execution_id: "exec-1",
696
+ workflow_id: "wf-1",
697
+ input: {},
698
+ history: ["step1"]
699
+ )
700
+
701
+ new_state = state.add_history("step2")
702
+
703
+ assert_equal ["step1"], state.history
704
+ assert_equal ["step1", "step2"], new_state.history
705
+ end
706
+ end
707
+ ```
708
+
709
+ ### 4. `test/core/engine_test.rb`
710
+
711
+ ```ruby
712
+ # frozen_string_literal: true
713
+
714
+ require "test_helper"
715
+
716
+ class EngineTest < DurableWorkflowTest
717
+ def test_run_simple_workflow
718
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
719
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
720
+
721
+ result = engine.run({ value: 21 })
722
+
723
+ assert result.completed?
724
+ assert_equal 42, result.output[:result]
725
+ end
726
+
727
+ def test_run_with_execution_id
728
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
729
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
730
+
731
+ result = engine.run({ value: 10 }, execution_id: "my-exec-id")
732
+
733
+ assert_equal "my-exec-id", result.execution_id
734
+ assert result.completed?
735
+ end
736
+
737
+ def test_run_generates_execution_id
738
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
739
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
740
+
741
+ result = engine.run({ value: 10 })
742
+
743
+ refute_nil result.execution_id
744
+ assert_match(/\A[0-9a-f-]{36}\z/, result.execution_id)
745
+ end
746
+
747
+ def test_run_saves_state
748
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
749
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
750
+
751
+ result = engine.run({ value: 10 })
752
+ state = @store.load(result.execution_id)
753
+
754
+ refute_nil state
755
+ assert_equal 20, state.ctx[:result]
756
+ end
757
+
758
+ def test_run_router_path_a
759
+ workflow = DurableWorkflow::Core::Parser.parse(router_workflow_yaml)
760
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
761
+
762
+ result = engine.run({ path: "a" })
763
+
764
+ assert result.completed?
765
+ assert_equal "went_a", result.output[:result]
766
+ end
767
+
768
+ def test_run_router_path_b
769
+ workflow = DurableWorkflow::Core::Parser.parse(router_workflow_yaml)
770
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
771
+
772
+ result = engine.run({ path: "b" })
773
+
774
+ assert result.completed?
775
+ assert_equal "went_b", result.output[:result]
776
+ end
777
+
778
+ def test_run_router_default
779
+ workflow = DurableWorkflow::Core::Parser.parse(router_workflow_yaml)
780
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
781
+
782
+ result = engine.run({ path: "unknown" })
783
+
784
+ assert result.completed?
785
+ assert_equal "went_default", result.output[:result]
786
+ end
787
+
788
+ def test_run_loop
789
+ workflow = DurableWorkflow::Core::Parser.parse(loop_workflow_yaml)
790
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
791
+
792
+ result = engine.run({ items: [1, 2, 3, 4, 5] })
793
+
794
+ assert result.completed?
795
+ assert_equal 5, result.output[:counter]
796
+ assert_equal 15, result.output[:sum]
797
+ end
798
+
799
+ def test_run_halt_and_resume
800
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
801
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
802
+
803
+ result = engine.run({})
804
+
805
+ assert result.halted?
806
+ assert_equal "Waiting for input", result.halt.data[:message]
807
+
808
+ # Resume with response
809
+ result = engine.resume(result.execution_id, response: "user_input")
810
+
811
+ assert result.completed?
812
+ assert_equal "user_input", result.output[:result]
813
+ end
814
+
815
+ def test_run_approval_approved
816
+ workflow = DurableWorkflow::Core::Parser.parse(approval_workflow_yaml)
817
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
818
+
819
+ result = engine.run({})
820
+
821
+ assert result.halted?
822
+ assert_equal "Do you approve?", result.halt.prompt
823
+
824
+ result = engine.resume(result.execution_id, approved: true)
825
+
826
+ assert result.completed?
827
+ assert_equal "approved", result.output[:result]
828
+ end
829
+
830
+ def test_run_approval_rejected
831
+ workflow = DurableWorkflow::Core::Parser.parse(approval_workflow_yaml)
832
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
833
+
834
+ result = engine.run({})
835
+ result = engine.resume(result.execution_id, approved: false)
836
+
837
+ assert result.completed?
838
+ assert_equal "rejected", result.output[:result]
839
+ end
840
+
841
+ def test_run_parallel
842
+ workflow = DurableWorkflow::Core::Parser.parse(parallel_workflow_yaml)
843
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
844
+
845
+ result = engine.run({})
846
+
847
+ assert result.completed?
848
+ assert_equal "from_a", result.output[:a_result]
849
+ assert_equal "from_b", result.output[:b_result]
850
+ end
851
+
852
+ def test_run_records_entries
853
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
854
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
855
+
856
+ result = engine.run({ value: 10 })
857
+ entries = @store.entries(result.execution_id)
858
+
859
+ refute_empty entries
860
+ assert entries.any? { _1.step_id == "start" }
861
+ assert entries.any? { _1.step_id == "process" }
862
+ assert entries.any? { _1.step_id == "done" }
863
+ end
864
+
865
+ def test_resume_nonexistent_execution_fails
866
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
867
+ engine = DurableWorkflow::Core::Engine.new(workflow, store: @store)
868
+
869
+ error = assert_raises(DurableWorkflow::ExecutionError) do
870
+ engine.resume("nonexistent-id")
871
+ end
872
+
873
+ assert_match(/not found/i, error.message)
874
+ end
875
+ end
876
+ ```
877
+
878
+ ### 5. `test/core/registry_test.rb`
879
+
880
+ ```ruby
881
+ # frozen_string_literal: true
882
+
883
+ require "test_helper"
884
+
885
+ class RegistryTest < DurableWorkflowTest
886
+ def test_register_and_get_executor
887
+ registry = DurableWorkflow::Core::Executors::Registry
888
+
889
+ # Core executors should be registered
890
+ assert registry.registered?("start")
891
+ assert registry.registered?("end")
892
+ assert registry.registered?("assign")
893
+ assert registry.registered?("call")
894
+ assert registry.registered?("router")
895
+ assert registry.registered?("loop")
896
+ assert registry.registered?("halt")
897
+ assert registry.registered?("approval")
898
+ assert registry.registered?("transform")
899
+ assert registry.registered?("parallel")
900
+ assert registry.registered?("workflow")
901
+ end
902
+
903
+ def test_get_executor_class
904
+ registry = DurableWorkflow::Core::Executors::Registry
905
+
906
+ klass = registry.get("assign")
907
+
908
+ assert_equal DurableWorkflow::Core::Executors::Assign, klass
909
+ end
910
+
911
+ def test_unregistered_type_returns_nil
912
+ registry = DurableWorkflow::Core::Executors::Registry
913
+
914
+ refute registry.registered?("nonexistent_type")
915
+ assert_nil registry.get("nonexistent_type")
916
+ end
917
+
918
+ def test_register_custom_executor
919
+ registry = DurableWorkflow::Core::Executors::Registry
920
+
921
+ custom_executor = Class.new(DurableWorkflow::Core::Executors::Base) do
922
+ def call(state)
923
+ continue(state.with_ctx(custom: true))
924
+ end
925
+ end
926
+
927
+ registry.register("custom", custom_executor)
928
+
929
+ assert registry.registered?("custom")
930
+ assert_equal custom_executor, registry.get("custom")
931
+ ensure
932
+ # Clean up
933
+ registry.instance_variable_get(:@executors).delete("custom")
934
+ end
935
+
936
+ def test_types_returns_all_registered
937
+ registry = DurableWorkflow::Core::Executors::Registry
938
+
939
+ types = registry.types
940
+
941
+ assert_includes types, "start"
942
+ assert_includes types, "end"
943
+ assert_includes types, "assign"
944
+ end
945
+ end
946
+ ```
947
+
948
+ ### 6. `test/core/resolver_test.rb`
949
+
950
+ ```ruby
951
+ # frozen_string_literal: true
952
+
953
+ require "test_helper"
954
+
955
+ class ResolverTest < DurableWorkflowTest
956
+ def setup
957
+ super
958
+ @state = DurableWorkflow::Core::State.new(
959
+ execution_id: "exec-1",
960
+ workflow_id: "wf-1",
961
+ input: { name: "Alice", value: 42, nested: { deep: "data" } },
962
+ ctx: { counter: 10, items: [1, 2, 3] }
963
+ )
964
+ @resolver = DurableWorkflow::Core::Resolver.new(@state)
965
+ end
966
+
967
+ def test_resolve_input_value
968
+ result = @resolver.resolve("$.input.name")
969
+
970
+ assert_equal "Alice", result
971
+ end
972
+
973
+ def test_resolve_ctx_value
974
+ result = @resolver.resolve("$.ctx.counter")
975
+
976
+ assert_equal 10, result
977
+ end
978
+
979
+ def test_resolve_nested_input
980
+ result = @resolver.resolve("$.input.nested.deep")
981
+
982
+ assert_equal "data", result
983
+ end
984
+
985
+ def test_resolve_array_access
986
+ result = @resolver.resolve("$.ctx.items[1]")
987
+
988
+ assert_equal 2, result
989
+ end
990
+
991
+ def test_resolve_expression
992
+ result = @resolver.resolve("$.input.value * 2")
993
+
994
+ assert_equal 84, result
995
+ end
996
+
997
+ def test_resolve_string_concatenation
998
+ result = @resolver.resolve("'Hello, ' + $.input.name")
999
+
1000
+ assert_equal "Hello, Alice", result
1001
+ end
1002
+
1003
+ def test_resolve_comparison
1004
+ result = @resolver.resolve("$.input.value > 40")
1005
+
1006
+ assert_equal true, result
1007
+ end
1008
+
1009
+ def test_resolve_static_string
1010
+ result = @resolver.resolve("'static value'")
1011
+
1012
+ assert_equal "static value", result
1013
+ end
1014
+
1015
+ def test_resolve_static_number
1016
+ result = @resolver.resolve("123")
1017
+
1018
+ assert_equal 123, result
1019
+ end
1020
+
1021
+ def test_resolve_hash
1022
+ hash = { key: "$.input.name", static: "value" }
1023
+ result = @resolver.resolve(hash)
1024
+
1025
+ assert_equal({ key: "Alice", static: "value" }, result)
1026
+ end
1027
+
1028
+ def test_resolve_array
1029
+ arr = ["$.input.name", "$.ctx.counter"]
1030
+ result = @resolver.resolve(arr)
1031
+
1032
+ assert_equal ["Alice", 10], result
1033
+ end
1034
+
1035
+ def test_resolve_missing_path_returns_nil
1036
+ result = @resolver.resolve("$.input.nonexistent")
1037
+
1038
+ assert_nil result
1039
+ end
1040
+ end
1041
+ ```
1042
+
1043
+ ### 7. `test/core/condition_test.rb`
1044
+
1045
+ ```ruby
1046
+ # frozen_string_literal: true
1047
+
1048
+ require "test_helper"
1049
+
1050
+ class ConditionTest < DurableWorkflowTest
1051
+ def setup
1052
+ super
1053
+ @state = DurableWorkflow::Core::State.new(
1054
+ execution_id: "exec-1",
1055
+ workflow_id: "wf-1",
1056
+ input: { status: "active", count: 5 },
1057
+ ctx: { approved: true, items: [1, 2, 3] }
1058
+ )
1059
+ end
1060
+
1061
+ def test_evaluate_equality
1062
+ result = DurableWorkflow::Core::Condition.evaluate(
1063
+ "$.input.status == 'active'",
1064
+ @state
1065
+ )
1066
+
1067
+ assert result
1068
+ end
1069
+
1070
+ def test_evaluate_inequality
1071
+ result = DurableWorkflow::Core::Condition.evaluate(
1072
+ "$.input.status != 'inactive'",
1073
+ @state
1074
+ )
1075
+
1076
+ assert result
1077
+ end
1078
+
1079
+ def test_evaluate_greater_than
1080
+ result = DurableWorkflow::Core::Condition.evaluate(
1081
+ "$.input.count > 3",
1082
+ @state
1083
+ )
1084
+
1085
+ assert result
1086
+ end
1087
+
1088
+ def test_evaluate_less_than
1089
+ result = DurableWorkflow::Core::Condition.evaluate(
1090
+ "$.input.count < 10",
1091
+ @state
1092
+ )
1093
+
1094
+ assert result
1095
+ end
1096
+
1097
+ def test_evaluate_boolean_ctx
1098
+ result = DurableWorkflow::Core::Condition.evaluate(
1099
+ "$.ctx.approved",
1100
+ @state
1101
+ )
1102
+
1103
+ assert result
1104
+ end
1105
+
1106
+ def test_evaluate_and
1107
+ result = DurableWorkflow::Core::Condition.evaluate(
1108
+ "$.ctx.approved && $.input.count > 0",
1109
+ @state
1110
+ )
1111
+
1112
+ assert result
1113
+ end
1114
+
1115
+ def test_evaluate_or
1116
+ result = DurableWorkflow::Core::Condition.evaluate(
1117
+ "$.input.status == 'inactive' || $.ctx.approved",
1118
+ @state
1119
+ )
1120
+
1121
+ assert result
1122
+ end
1123
+
1124
+ def test_evaluate_not
1125
+ result = DurableWorkflow::Core::Condition.evaluate(
1126
+ "!$.ctx.approved",
1127
+ @state
1128
+ )
1129
+
1130
+ refute result
1131
+ end
1132
+
1133
+ def test_evaluate_array_includes
1134
+ result = DurableWorkflow::Core::Condition.evaluate(
1135
+ "$.ctx.items.includes(2)",
1136
+ @state
1137
+ )
1138
+
1139
+ assert result
1140
+ end
1141
+
1142
+ def test_evaluate_array_length
1143
+ result = DurableWorkflow::Core::Condition.evaluate(
1144
+ "$.ctx.items.length == 3",
1145
+ @state
1146
+ )
1147
+
1148
+ assert result
1149
+ end
1150
+
1151
+ def test_evaluate_false_condition
1152
+ result = DurableWorkflow::Core::Condition.evaluate(
1153
+ "$.input.count > 100",
1154
+ @state
1155
+ )
1156
+
1157
+ refute result
1158
+ end
1159
+ end
1160
+ ```
1161
+
1162
+ ### 8. `test/core/validator_test.rb`
1163
+
1164
+ ```ruby
1165
+ # frozen_string_literal: true
1166
+
1167
+ require "test_helper"
1168
+
1169
+ class ValidatorTest < DurableWorkflowTest
1170
+ def test_valid_workflow_passes
1171
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1172
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1173
+
1174
+ result = validator.validate
1175
+
1176
+ assert result.valid?
1177
+ assert_empty result.errors
1178
+ end
1179
+
1180
+ def test_missing_start_step_fails
1181
+ yaml = <<~YAML
1182
+ id: no_start
1183
+ name: No Start
1184
+ version: "1.0"
1185
+ steps:
1186
+ - id: process
1187
+ type: assign
1188
+ config:
1189
+ assignments:
1190
+ x: 1
1191
+ next: done
1192
+ - id: done
1193
+ type: end
1194
+ YAML
1195
+
1196
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1197
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1198
+
1199
+ result = validator.validate
1200
+
1201
+ refute result.valid?
1202
+ assert result.errors.any? { _1.include?("start") }
1203
+ end
1204
+
1205
+ def test_missing_end_step_fails
1206
+ yaml = <<~YAML
1207
+ id: no_end
1208
+ name: No End
1209
+ version: "1.0"
1210
+ steps:
1211
+ - id: start
1212
+ type: start
1213
+ next: process
1214
+ - id: process
1215
+ type: assign
1216
+ config:
1217
+ assignments:
1218
+ x: 1
1219
+ YAML
1220
+
1221
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1222
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1223
+
1224
+ result = validator.validate
1225
+
1226
+ refute result.valid?
1227
+ assert result.errors.any? { _1.include?("end") }
1228
+ end
1229
+
1230
+ def test_unreachable_step_warning
1231
+ yaml = <<~YAML
1232
+ id: unreachable
1233
+ name: Unreachable
1234
+ version: "1.0"
1235
+ steps:
1236
+ - id: start
1237
+ type: start
1238
+ next: done
1239
+ - id: orphan
1240
+ type: assign
1241
+ config:
1242
+ assignments:
1243
+ x: 1
1244
+ next: done
1245
+ - id: done
1246
+ type: end
1247
+ YAML
1248
+
1249
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1250
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1251
+
1252
+ result = validator.validate
1253
+
1254
+ assert result.valid? # Warnings don't fail validation
1255
+ assert result.warnings.any? { _1.include?("orphan") }
1256
+ end
1257
+
1258
+ def test_invalid_next_step_fails
1259
+ yaml = <<~YAML
1260
+ id: bad_next
1261
+ name: Bad Next
1262
+ version: "1.0"
1263
+ steps:
1264
+ - id: start
1265
+ type: start
1266
+ next: nonexistent
1267
+ - id: done
1268
+ type: end
1269
+ YAML
1270
+
1271
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1272
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1273
+
1274
+ result = validator.validate
1275
+
1276
+ refute result.valid?
1277
+ assert result.errors.any? { _1.include?("nonexistent") }
1278
+ end
1279
+
1280
+ def test_unknown_step_type_fails
1281
+ yaml = <<~YAML
1282
+ id: unknown_type
1283
+ name: Unknown Type
1284
+ version: "1.0"
1285
+ steps:
1286
+ - id: start
1287
+ type: start
1288
+ next: bad
1289
+ - id: bad
1290
+ type: nonexistent_type
1291
+ next: done
1292
+ - id: done
1293
+ type: end
1294
+ YAML
1295
+
1296
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1297
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1298
+
1299
+ result = validator.validate
1300
+
1301
+ refute result.valid?
1302
+ assert result.errors.any? { _1.include?("nonexistent_type") }
1303
+ end
1304
+
1305
+ def test_duplicate_step_ids_fail
1306
+ yaml = <<~YAML
1307
+ id: duplicate_ids
1308
+ name: Duplicate IDs
1309
+ version: "1.0"
1310
+ steps:
1311
+ - id: start
1312
+ type: start
1313
+ next: process
1314
+ - id: process
1315
+ type: assign
1316
+ config:
1317
+ assignments:
1318
+ x: 1
1319
+ next: process
1320
+ - id: process
1321
+ type: assign
1322
+ config:
1323
+ assignments:
1324
+ y: 2
1325
+ next: done
1326
+ - id: done
1327
+ type: end
1328
+ YAML
1329
+
1330
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1331
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1332
+
1333
+ result = validator.validate
1334
+
1335
+ refute result.valid?
1336
+ assert result.errors.any? { _1.include?("duplicate") || _1.include?("process") }
1337
+ end
1338
+
1339
+ def test_validate_bang_raises_on_invalid
1340
+ yaml = <<~YAML
1341
+ id: invalid
1342
+ name: Invalid
1343
+ version: "1.0"
1344
+ steps:
1345
+ - id: start
1346
+ type: start
1347
+ next: nowhere
1348
+ YAML
1349
+
1350
+ workflow = DurableWorkflow::Core::Parser.parse(yaml)
1351
+ validator = DurableWorkflow::Core::Validator.new(workflow)
1352
+
1353
+ assert_raises(DurableWorkflow::ValidationError) do
1354
+ validator.validate!
1355
+ end
1356
+ end
1357
+ end
1358
+ ```
1359
+
1360
+ ### 9. `test/core/executors/assign_test.rb`
1361
+
1362
+ ```ruby
1363
+ # frozen_string_literal: true
1364
+
1365
+ require "test_helper"
1366
+
1367
+ class AssignExecutorTest < DurableWorkflowTest
1368
+ def setup
1369
+ super
1370
+ @step = DurableWorkflow::Core::StepDef.new(
1371
+ id: "assign_step",
1372
+ type: "assign",
1373
+ config: {},
1374
+ next_step: "next"
1375
+ )
1376
+ @state = DurableWorkflow::Core::State.new(
1377
+ execution_id: "exec-1",
1378
+ workflow_id: "wf-1",
1379
+ input: { value: 10 },
1380
+ ctx: { existing: "keep" }
1381
+ )
1382
+ end
1383
+
1384
+ def test_assign_static_value
1385
+ @step = @step.with(config: { assignments: { result: "'static'" } })
1386
+ executor = DurableWorkflow::Core::Executors::Assign.new(@step, nil)
1387
+
1388
+ outcome = executor.call(@state)
1389
+
1390
+ assert_equal "static", outcome.state.ctx[:result]
1391
+ assert_equal "keep", outcome.state.ctx[:existing]
1392
+ assert_equal "next", outcome.next_step
1393
+ end
1394
+
1395
+ def test_assign_from_input
1396
+ @step = @step.with(config: { assignments: { doubled: "$.input.value * 2" } })
1397
+ executor = DurableWorkflow::Core::Executors::Assign.new(@step, nil)
1398
+
1399
+ outcome = executor.call(@state)
1400
+
1401
+ assert_equal 20, outcome.state.ctx[:doubled]
1402
+ end
1403
+
1404
+ def test_assign_multiple_values
1405
+ @step = @step.with(config: {
1406
+ assignments: {
1407
+ a: "$.input.value",
1408
+ b: "$.input.value + 5",
1409
+ c: "'constant'"
1410
+ }
1411
+ })
1412
+ executor = DurableWorkflow::Core::Executors::Assign.new(@step, nil)
1413
+
1414
+ outcome = executor.call(@state)
1415
+
1416
+ assert_equal 10, outcome.state.ctx[:a]
1417
+ assert_equal 15, outcome.state.ctx[:b]
1418
+ assert_equal "constant", outcome.state.ctx[:c]
1419
+ end
1420
+
1421
+ def test_assign_from_ctx
1422
+ @state = @state.with_ctx(source: 100)
1423
+ @step = @step.with(config: { assignments: { target: "$.ctx.source / 2" } })
1424
+ executor = DurableWorkflow::Core::Executors::Assign.new(@step, nil)
1425
+
1426
+ outcome = executor.call(@state)
1427
+
1428
+ assert_equal 50, outcome.state.ctx[:target]
1429
+ end
1430
+ end
1431
+ ```
1432
+
1433
+ ### 10. `test/core/executors/router_test.rb`
1434
+
1435
+ ```ruby
1436
+ # frozen_string_literal: true
1437
+
1438
+ require "test_helper"
1439
+
1440
+ class RouterExecutorTest < DurableWorkflowTest
1441
+ def setup
1442
+ super
1443
+ @step = DurableWorkflow::Core::StepDef.new(
1444
+ id: "router_step",
1445
+ type: "router",
1446
+ config: {
1447
+ routes: [
1448
+ { condition: "$.input.type == 'a'", next: "path_a" },
1449
+ { condition: "$.input.type == 'b'", next: "path_b" }
1450
+ ],
1451
+ default: "path_default"
1452
+ },
1453
+ next_step: nil
1454
+ )
1455
+ @state = DurableWorkflow::Core::State.new(
1456
+ execution_id: "exec-1",
1457
+ workflow_id: "wf-1",
1458
+ input: {},
1459
+ ctx: {}
1460
+ )
1461
+ end
1462
+
1463
+ def test_routes_to_first_match
1464
+ @state = @state.with(input: { type: "a" })
1465
+ executor = DurableWorkflow::Core::Executors::Router.new(@step, nil)
1466
+
1467
+ outcome = executor.call(@state)
1468
+
1469
+ assert_equal "path_a", outcome.next_step
1470
+ end
1471
+
1472
+ def test_routes_to_second_match
1473
+ @state = @state.with(input: { type: "b" })
1474
+ executor = DurableWorkflow::Core::Executors::Router.new(@step, nil)
1475
+
1476
+ outcome = executor.call(@state)
1477
+
1478
+ assert_equal "path_b", outcome.next_step
1479
+ end
1480
+
1481
+ def test_routes_to_default
1482
+ @state = @state.with(input: { type: "unknown" })
1483
+ executor = DurableWorkflow::Core::Executors::Router.new(@step, nil)
1484
+
1485
+ outcome = executor.call(@state)
1486
+
1487
+ assert_equal "path_default", outcome.next_step
1488
+ end
1489
+
1490
+ def test_routes_based_on_ctx
1491
+ @step = @step.with(config: {
1492
+ routes: [
1493
+ { condition: "$.ctx.score > 80", next: "high" },
1494
+ { condition: "$.ctx.score > 50", next: "medium" }
1495
+ ],
1496
+ default: "low"
1497
+ })
1498
+ @state = @state.with_ctx(score: 75)
1499
+ executor = DurableWorkflow::Core::Executors::Router.new(@step, nil)
1500
+
1501
+ outcome = executor.call(@state)
1502
+
1503
+ assert_equal "medium", outcome.next_step
1504
+ end
1505
+ end
1506
+ ```
1507
+
1508
+ ### 11. `test/core/executors/loop_test.rb`
1509
+
1510
+ ```ruby
1511
+ # frozen_string_literal: true
1512
+
1513
+ require "test_helper"
1514
+
1515
+ class LoopExecutorTest < DurableWorkflowTest
1516
+ def setup
1517
+ super
1518
+ @workflow = DurableWorkflow::Core::Parser.parse(loop_workflow_yaml)
1519
+ end
1520
+
1521
+ def test_loop_iterates_all_items
1522
+ engine = DurableWorkflow::Core::Engine.new(@workflow, store: @store)
1523
+
1524
+ result = engine.run({ items: [10, 20, 30] })
1525
+
1526
+ assert result.completed?
1527
+ assert_equal 3, result.output[:counter]
1528
+ assert_equal 60, result.output[:sum]
1529
+ end
1530
+
1531
+ def test_loop_empty_collection
1532
+ engine = DurableWorkflow::Core::Engine.new(@workflow, store: @store)
1533
+
1534
+ result = engine.run({ items: [] })
1535
+
1536
+ assert result.completed?
1537
+ assert_equal 0, result.output[:counter]
1538
+ assert_equal 0, result.output[:sum]
1539
+ end
1540
+
1541
+ def test_loop_single_item
1542
+ engine = DurableWorkflow::Core::Engine.new(@workflow, store: @store)
1543
+
1544
+ result = engine.run({ items: [100] })
1545
+
1546
+ assert result.completed?
1547
+ assert_equal 1, result.output[:counter]
1548
+ assert_equal 100, result.output[:sum]
1549
+ end
1550
+ end
1551
+ ```
1552
+
1553
+ ### 12. `test/core/executors/halt_test.rb`
1554
+
1555
+ ```ruby
1556
+ # frozen_string_literal: true
1557
+
1558
+ require "test_helper"
1559
+
1560
+ class HaltExecutorTest < DurableWorkflowTest
1561
+ def setup
1562
+ super
1563
+ @step = DurableWorkflow::Core::StepDef.new(
1564
+ id: "halt_step",
1565
+ type: "halt",
1566
+ config: {
1567
+ data: { reason: "waiting", message: "Please provide input" }
1568
+ },
1569
+ next_step: "after_halt"
1570
+ )
1571
+ @state = DurableWorkflow::Core::State.new(
1572
+ execution_id: "exec-1",
1573
+ workflow_id: "wf-1",
1574
+ input: {},
1575
+ ctx: { existing: "data" }
1576
+ )
1577
+ end
1578
+
1579
+ def test_halt_returns_halted_outcome
1580
+ executor = DurableWorkflow::Core::Executors::Halt.new(@step, nil)
1581
+
1582
+ outcome = executor.call(@state)
1583
+
1584
+ assert outcome.halted?
1585
+ assert_equal({ reason: "waiting", message: "Please provide input" }, outcome.result.data)
1586
+ end
1587
+
1588
+ def test_halt_preserves_next_step
1589
+ executor = DurableWorkflow::Core::Executors::Halt.new(@step, nil)
1590
+
1591
+ outcome = executor.call(@state)
1592
+
1593
+ assert_equal "after_halt", outcome.next_step
1594
+ end
1595
+
1596
+ def test_halt_with_dynamic_data
1597
+ @step = @step.with(config: {
1598
+ data: {
1599
+ value: "$.input.amount",
1600
+ status: "$.ctx.status"
1601
+ }
1602
+ })
1603
+ @state = @state.with(input: { amount: 100 }).with_ctx(status: "pending")
1604
+ executor = DurableWorkflow::Core::Executors::Halt.new(@step, nil)
1605
+
1606
+ outcome = executor.call(@state)
1607
+
1608
+ assert_equal({ value: 100, status: "pending" }, outcome.result.data)
1609
+ end
1610
+ end
1611
+ ```
1612
+
1613
+ ### 13. `test/core/executors/call_test.rb`
1614
+
1615
+ ```ruby
1616
+ # frozen_string_literal: true
1617
+
1618
+ require "test_helper"
1619
+
1620
+ class CallExecutorTest < DurableWorkflowTest
1621
+ def setup
1622
+ super
1623
+ # Register a test service
1624
+ DurableWorkflow.register_service(:test_service, TestService.new)
1625
+ end
1626
+
1627
+ def teardown
1628
+ super
1629
+ DurableWorkflow.services.delete(:test_service)
1630
+ end
1631
+
1632
+ class TestService
1633
+ def echo(message:)
1634
+ { echoed: message }
1635
+ end
1636
+
1637
+ def add(a:, b:)
1638
+ { sum: a + b }
1639
+ end
1640
+
1641
+ def failing
1642
+ raise "Service error"
1643
+ end
1644
+ end
1645
+
1646
+ def test_call_service_method
1647
+ step = DurableWorkflow::Core::StepDef.new(
1648
+ id: "call_step",
1649
+ type: "call",
1650
+ config: {
1651
+ service: "test_service",
1652
+ method: "echo",
1653
+ args: { message: "'Hello'" }
1654
+ },
1655
+ next_step: "next"
1656
+ )
1657
+ state = DurableWorkflow::Core::State.new(
1658
+ execution_id: "exec-1",
1659
+ workflow_id: "wf-1",
1660
+ input: {},
1661
+ ctx: {}
1662
+ )
1663
+ executor = DurableWorkflow::Core::Executors::Call.new(step, nil)
1664
+
1665
+ outcome = executor.call(state)
1666
+
1667
+ assert_equal({ echoed: "Hello" }, outcome.result.output)
1668
+ assert_equal "next", outcome.next_step
1669
+ end
1670
+
1671
+ def test_call_with_resolved_args
1672
+ step = DurableWorkflow::Core::StepDef.new(
1673
+ id: "call_step",
1674
+ type: "call",
1675
+ config: {
1676
+ service: "test_service",
1677
+ method: "add",
1678
+ args: { a: "$.input.x", b: "$.input.y" }
1679
+ },
1680
+ next_step: "next"
1681
+ )
1682
+ state = DurableWorkflow::Core::State.new(
1683
+ execution_id: "exec-1",
1684
+ workflow_id: "wf-1",
1685
+ input: { x: 10, y: 20 },
1686
+ ctx: {}
1687
+ )
1688
+ executor = DurableWorkflow::Core::Executors::Call.new(step, nil)
1689
+
1690
+ outcome = executor.call(state)
1691
+
1692
+ assert_equal({ sum: 30 }, outcome.result.output)
1693
+ end
1694
+
1695
+ def test_call_stores_result_in_ctx
1696
+ step = DurableWorkflow::Core::StepDef.new(
1697
+ id: "call_step",
1698
+ type: "call",
1699
+ config: {
1700
+ service: "test_service",
1701
+ method: "echo",
1702
+ args: { message: "'test'" },
1703
+ result_key: "call_result"
1704
+ },
1705
+ next_step: "next"
1706
+ )
1707
+ state = DurableWorkflow::Core::State.new(
1708
+ execution_id: "exec-1",
1709
+ workflow_id: "wf-1",
1710
+ input: {},
1711
+ ctx: {}
1712
+ )
1713
+ executor = DurableWorkflow::Core::Executors::Call.new(step, nil)
1714
+
1715
+ outcome = executor.call(state)
1716
+
1717
+ assert_equal({ echoed: "test" }, outcome.state.ctx[:call_result])
1718
+ end
1719
+
1720
+ def test_call_unregistered_service_fails
1721
+ step = DurableWorkflow::Core::StepDef.new(
1722
+ id: "call_step",
1723
+ type: "call",
1724
+ config: {
1725
+ service: "nonexistent",
1726
+ method: "foo"
1727
+ },
1728
+ next_step: "next"
1729
+ )
1730
+ state = DurableWorkflow::Core::State.new(
1731
+ execution_id: "exec-1",
1732
+ workflow_id: "wf-1",
1733
+ input: {},
1734
+ ctx: {}
1735
+ )
1736
+ executor = DurableWorkflow::Core::Executors::Call.new(step, nil)
1737
+
1738
+ assert_raises(DurableWorkflow::ExecutionError) do
1739
+ executor.call(state)
1740
+ end
1741
+ end
1742
+ end
1743
+ ```
1744
+
1745
+ ### 14. `test/parser_test.rb`
1746
+
1747
+ ```ruby
1748
+ # frozen_string_literal: true
1749
+
1750
+ require "test_helper"
1751
+
1752
+ class ParserTest < DurableWorkflowTest
1753
+ def test_parse_simple_workflow
1754
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1755
+
1756
+ assert_equal "test_workflow", workflow.id
1757
+ assert_equal "Test Workflow", workflow.name
1758
+ assert_equal "1.0", workflow.version
1759
+ assert_equal 3, workflow.steps.size
1760
+ end
1761
+
1762
+ def test_parse_creates_step_defs
1763
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1764
+
1765
+ start_step = workflow.steps.find { _1.id == "start" }
1766
+ assert_equal "start", start_step.type
1767
+ assert_equal "process", start_step.next_step
1768
+
1769
+ process_step = workflow.steps.find { _1.id == "process" }
1770
+ assert_equal "assign", process_step.type
1771
+ assert_equal({ assignments: { result: "$.input.value * 2" } }, process_step.config)
1772
+ end
1773
+
1774
+ def test_parse_with_input_schema
1775
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1776
+
1777
+ expected_schema = {
1778
+ type: "object",
1779
+ properties: { value: { type: "integer" } }
1780
+ }
1781
+ assert_equal expected_schema, workflow.input_schema
1782
+ end
1783
+
1784
+ def test_parse_router_config
1785
+ workflow = DurableWorkflow::Core::Parser.parse(router_workflow_yaml)
1786
+
1787
+ route_step = workflow.steps.find { _1.id == "route" }
1788
+ assert_equal "router", route_step.type
1789
+ assert_equal 2, route_step.config[:routes].size
1790
+ assert_equal "path_default", route_step.config[:default]
1791
+ end
1792
+
1793
+ def test_parse_loop_config
1794
+ workflow = DurableWorkflow::Core::Parser.parse(loop_workflow_yaml)
1795
+
1796
+ loop_step = workflow.steps.find { _1.id == "loop" }
1797
+ assert_equal "loop", loop_step.type
1798
+ assert_equal "$.input.items", loop_step.config[:collection]
1799
+ assert_equal "item", loop_step.config[:item_var]
1800
+ refute_empty loop_step.config[:body]
1801
+ end
1802
+
1803
+ def test_parse_parallel_config
1804
+ workflow = DurableWorkflow::Core::Parser.parse(parallel_workflow_yaml)
1805
+
1806
+ parallel_step = workflow.steps.find { _1.id == "parallel" }
1807
+ assert_equal "parallel", parallel_step.type
1808
+ assert_equal 2, parallel_step.config[:branches].keys.size
1809
+ assert_equal "all", parallel_step.config[:merge_strategy]
1810
+ end
1811
+
1812
+ def test_parse_from_file
1813
+ # Create temp file
1814
+ require "tempfile"
1815
+ file = Tempfile.new(["workflow", ".yml"])
1816
+ file.write(simple_workflow_yaml)
1817
+ file.close
1818
+
1819
+ workflow = DurableWorkflow::Core::Parser.parse_file(file.path)
1820
+
1821
+ assert_equal "test_workflow", workflow.id
1822
+ ensure
1823
+ file.unlink
1824
+ end
1825
+
1826
+ def test_parse_invalid_yaml_raises
1827
+ assert_raises(DurableWorkflow::ParseError) do
1828
+ DurableWorkflow::Core::Parser.parse("not: valid: yaml: :")
1829
+ end
1830
+ end
1831
+
1832
+ def test_parse_missing_id_raises
1833
+ yaml = <<~YAML
1834
+ name: No ID
1835
+ version: "1.0"
1836
+ steps: []
1837
+ YAML
1838
+
1839
+ assert_raises(DurableWorkflow::ParseError) do
1840
+ DurableWorkflow::Core::Parser.parse(yaml)
1841
+ end
1842
+ end
1843
+
1844
+ def test_before_parse_hook
1845
+ hook_called = false
1846
+ DurableWorkflow::Core::Parser.before_parse do |yaml|
1847
+ hook_called = true
1848
+ yaml
1849
+ end
1850
+
1851
+ DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1852
+
1853
+ assert hook_called
1854
+ ensure
1855
+ DurableWorkflow::Core::Parser.instance_variable_get(:@before_hooks).clear
1856
+ end
1857
+
1858
+ def test_after_parse_hook
1859
+ DurableWorkflow::Core::Parser.after_parse do |workflow|
1860
+ workflow.with(extensions: workflow.extensions.merge(test: { added: true }))
1861
+ end
1862
+
1863
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1864
+
1865
+ assert_equal({ added: true }, workflow.extensions[:test])
1866
+ ensure
1867
+ DurableWorkflow::Core::Parser.instance_variable_get(:@after_hooks).clear
1868
+ end
1869
+
1870
+ def test_config_transformer_hook
1871
+ DurableWorkflow::Core::Parser.transform_config("assign") do |config|
1872
+ config.merge(transformed: true)
1873
+ end
1874
+
1875
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
1876
+ assign_step = workflow.steps.find { _1.type == "assign" }
1877
+
1878
+ assert assign_step.config[:transformed]
1879
+ ensure
1880
+ DurableWorkflow::Core::Parser.instance_variable_get(:@config_transformers).clear
1881
+ end
1882
+ end
1883
+ ```
1884
+
1885
+ ### 15. `test/storage/redis_test.rb`
1886
+
1887
+ ```ruby
1888
+ # frozen_string_literal: true
1889
+
1890
+ require "test_helper"
1891
+
1892
+ class RedisStorageTest < DurableWorkflowTest
1893
+ def setup
1894
+ skip "Redis not available" unless redis_available?
1895
+
1896
+ @redis = ::Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/15"))
1897
+ @redis.flushdb
1898
+ @store = DurableWorkflow::Storage::Redis.new(redis: @redis)
1899
+ end
1900
+
1901
+ def teardown
1902
+ @redis&.flushdb
1903
+ end
1904
+
1905
+ def test_save_and_load_state
1906
+ state = DurableWorkflow::Core::State.new(
1907
+ execution_id: "exec-1",
1908
+ workflow_id: "wf-1",
1909
+ input: { value: 42 },
1910
+ ctx: { result: 84 },
1911
+ current_step: "process"
1912
+ )
1913
+
1914
+ @store.save(state)
1915
+ loaded = @store.load("exec-1")
1916
+
1917
+ assert_equal "exec-1", loaded.execution_id
1918
+ assert_equal "wf-1", loaded.workflow_id
1919
+ assert_equal({ value: 42 }, loaded.input)
1920
+ assert_equal({ result: 84 }, loaded.ctx)
1921
+ assert_equal "process", loaded.current_step
1922
+ end
1923
+
1924
+ def test_load_nonexistent_returns_nil
1925
+ result = @store.load("nonexistent")
1926
+
1927
+ assert_nil result
1928
+ end
1929
+
1930
+ def test_record_and_get_entries
1931
+ entry1 = DurableWorkflow::Core::Entry.new(
1932
+ id: "entry-1",
1933
+ execution_id: "exec-1",
1934
+ step_id: "step-1",
1935
+ step_type: "assign",
1936
+ action: :execute,
1937
+ duration_ms: 10,
1938
+ timestamp: Time.now
1939
+ )
1940
+ entry2 = DurableWorkflow::Core::Entry.new(
1941
+ id: "entry-2",
1942
+ execution_id: "exec-1",
1943
+ step_id: "step-2",
1944
+ step_type: "call",
1945
+ action: :execute,
1946
+ duration_ms: 20,
1947
+ timestamp: Time.now
1948
+ )
1949
+
1950
+ @store.record(entry1)
1951
+ @store.record(entry2)
1952
+ entries = @store.entries("exec-1")
1953
+
1954
+ assert_equal 2, entries.size
1955
+ assert_equal "step-1", entries[0].step_id
1956
+ assert_equal "step-2", entries[1].step_id
1957
+ end
1958
+
1959
+ def test_find_by_workflow_id
1960
+ state1 = DurableWorkflow::Core::State.new(
1961
+ execution_id: "exec-1",
1962
+ workflow_id: "wf-1",
1963
+ input: {}
1964
+ )
1965
+ state2 = DurableWorkflow::Core::State.new(
1966
+ execution_id: "exec-2",
1967
+ workflow_id: "wf-1",
1968
+ input: {}
1969
+ )
1970
+ state3 = DurableWorkflow::Core::State.new(
1971
+ execution_id: "exec-3",
1972
+ workflow_id: "wf-2",
1973
+ input: {}
1974
+ )
1975
+
1976
+ @store.save(state1)
1977
+ @store.save(state2)
1978
+ @store.save(state3)
1979
+
1980
+ results = @store.find(workflow_id: "wf-1")
1981
+
1982
+ assert_equal 2, results.size
1983
+ assert results.all? { _1.workflow_id == "wf-1" }
1984
+ end
1985
+
1986
+ def test_find_by_status
1987
+ state1 = DurableWorkflow::Core::State.new(
1988
+ execution_id: "exec-1",
1989
+ workflow_id: "wf-1",
1990
+ input: {},
1991
+ ctx: { _status: :completed }
1992
+ )
1993
+ state2 = DurableWorkflow::Core::State.new(
1994
+ execution_id: "exec-2",
1995
+ workflow_id: "wf-1",
1996
+ input: {},
1997
+ ctx: { _status: :halted }
1998
+ )
1999
+
2000
+ @store.save(state1)
2001
+ @store.save(state2)
2002
+
2003
+ results = @store.find(status: :completed)
2004
+
2005
+ assert_equal 1, results.size
2006
+ assert_equal "exec-1", results[0].execution_id
2007
+ end
2008
+
2009
+ def test_delete_execution
2010
+ state = DurableWorkflow::Core::State.new(
2011
+ execution_id: "exec-1",
2012
+ workflow_id: "wf-1",
2013
+ input: {}
2014
+ )
2015
+ entry = DurableWorkflow::Core::Entry.new(
2016
+ id: "entry-1",
2017
+ execution_id: "exec-1",
2018
+ step_id: "step-1",
2019
+ step_type: "assign",
2020
+ action: :execute,
2021
+ timestamp: Time.now
2022
+ )
2023
+
2024
+ @store.save(state)
2025
+ @store.record(entry)
2026
+
2027
+ result = @store.delete("exec-1")
2028
+
2029
+ assert result
2030
+ assert_nil @store.load("exec-1")
2031
+ assert_empty @store.entries("exec-1")
2032
+ end
2033
+
2034
+ def test_execution_ids
2035
+ state1 = DurableWorkflow::Core::State.new(
2036
+ execution_id: "exec-1",
2037
+ workflow_id: "wf-1",
2038
+ input: {}
2039
+ )
2040
+ state2 = DurableWorkflow::Core::State.new(
2041
+ execution_id: "exec-2",
2042
+ workflow_id: "wf-1",
2043
+ input: {}
2044
+ )
2045
+
2046
+ @store.save(state1)
2047
+ @store.save(state2)
2048
+
2049
+ ids = @store.execution_ids
2050
+
2051
+ assert_includes ids, "exec-1"
2052
+ assert_includes ids, "exec-2"
2053
+ end
2054
+
2055
+ private
2056
+
2057
+ def redis_available?
2058
+ ::Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/15")).ping
2059
+ true
2060
+ rescue
2061
+ false
2062
+ end
2063
+ end
2064
+ ```
2065
+
2066
+ ### 16. `test/runners/sync_test.rb`
2067
+
2068
+ ```ruby
2069
+ # frozen_string_literal: true
2070
+
2071
+ require "test_helper"
2072
+
2073
+ class SyncRunnerTest < DurableWorkflowTest
2074
+ def test_run_workflow
2075
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2076
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2077
+
2078
+ result = runner.run({ value: 21 })
2079
+
2080
+ assert result.completed?
2081
+ assert_equal 42, result.output[:result]
2082
+ end
2083
+
2084
+ def test_run_with_execution_id
2085
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2086
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2087
+
2088
+ result = runner.run({ value: 10 }, execution_id: "my-id")
2089
+
2090
+ assert_equal "my-id", result.execution_id
2091
+ end
2092
+
2093
+ def test_run_until_complete_with_halt
2094
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
2095
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2096
+
2097
+ result = runner.run_until_complete({}) do |halt|
2098
+ assert_equal "Waiting for input", halt.data[:message]
2099
+ "user_response"
2100
+ end
2101
+
2102
+ assert result.completed?
2103
+ assert_equal "user_response", result.output[:result]
2104
+ end
2105
+
2106
+ def test_run_until_complete_without_block_returns_halted
2107
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
2108
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2109
+
2110
+ result = runner.run_until_complete({})
2111
+
2112
+ assert result.halted?
2113
+ end
2114
+
2115
+ def test_resume_halted_workflow
2116
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
2117
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2118
+
2119
+ result = runner.run({})
2120
+ assert result.halted?
2121
+
2122
+ result = runner.resume(result.execution_id, response: "resumed")
2123
+
2124
+ assert result.completed?
2125
+ assert_equal "resumed", result.output[:result]
2126
+ end
2127
+
2128
+ def test_resume_approval_approved
2129
+ workflow = DurableWorkflow::Core::Parser.parse(approval_workflow_yaml)
2130
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2131
+
2132
+ result = runner.run({})
2133
+ result = runner.resume(result.execution_id, approved: true)
2134
+
2135
+ assert result.completed?
2136
+ assert_equal "approved", result.output[:result]
2137
+ end
2138
+
2139
+ def test_resume_approval_rejected
2140
+ workflow = DurableWorkflow::Core::Parser.parse(approval_workflow_yaml)
2141
+ runner = DurableWorkflow::Runners::Sync.new(workflow, store: @store)
2142
+
2143
+ result = runner.run({})
2144
+ result = runner.resume(result.execution_id, approved: false)
2145
+
2146
+ assert result.completed?
2147
+ assert_equal "rejected", result.output[:result]
2148
+ end
2149
+ end
2150
+ ```
2151
+
2152
+ ### 17. `test/runners/stream_test.rb`
2153
+
2154
+ ```ruby
2155
+ # frozen_string_literal: true
2156
+
2157
+ require "test_helper"
2158
+
2159
+ class StreamRunnerTest < DurableWorkflowTest
2160
+ def test_emits_workflow_started_event
2161
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2162
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2163
+
2164
+ events = []
2165
+ runner.subscribe { |e| events << e }
2166
+
2167
+ runner.run({ value: 10 })
2168
+
2169
+ assert events.any? { _1.type == "workflow.started" }
2170
+ end
2171
+
2172
+ def test_emits_workflow_completed_event
2173
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2174
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2175
+
2176
+ events = []
2177
+ runner.subscribe { |e| events << e }
2178
+
2179
+ runner.run({ value: 10 })
2180
+
2181
+ assert events.any? { _1.type == "workflow.completed" }
2182
+ end
2183
+
2184
+ def test_emits_step_events
2185
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2186
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2187
+
2188
+ events = []
2189
+ runner.subscribe { |e| events << e }
2190
+
2191
+ runner.run({ value: 10 })
2192
+
2193
+ step_started = events.select { _1.type == "step.started" }
2194
+ step_completed = events.select { _1.type == "step.completed" }
2195
+
2196
+ assert step_started.size >= 3
2197
+ assert step_completed.size >= 3
2198
+ end
2199
+
2200
+ def test_emits_workflow_halted_event
2201
+ workflow = DurableWorkflow::Core::Parser.parse(halt_workflow_yaml)
2202
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2203
+
2204
+ events = []
2205
+ runner.subscribe { |e| events << e }
2206
+
2207
+ runner.run({})
2208
+
2209
+ halted_event = events.find { _1.type == "workflow.halted" }
2210
+ assert halted_event
2211
+ assert_equal "Waiting for input", halted_event.data[:halt][:message]
2212
+ end
2213
+
2214
+ def test_filter_events
2215
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2216
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2217
+
2218
+ events = []
2219
+ runner.subscribe(events: ["workflow.completed"]) { |e| events << e }
2220
+
2221
+ runner.run({ value: 10 })
2222
+
2223
+ assert_equal 1, events.size
2224
+ assert_equal "workflow.completed", events[0].type
2225
+ end
2226
+
2227
+ def test_event_to_sse
2228
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2229
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2230
+
2231
+ event = nil
2232
+ runner.subscribe(events: ["workflow.completed"]) { |e| event = e }
2233
+
2234
+ runner.run({ value: 10 })
2235
+
2236
+ sse = event.to_sse
2237
+
2238
+ assert_match(/event: workflow\.completed/, sse)
2239
+ assert_match(/data: \{/, sse)
2240
+ end
2241
+
2242
+ def test_multiple_subscribers
2243
+ workflow = DurableWorkflow::Core::Parser.parse(simple_workflow_yaml)
2244
+ runner = DurableWorkflow::Runners::Stream.new(workflow, store: @store)
2245
+
2246
+ events1 = []
2247
+ events2 = []
2248
+ runner.subscribe { |e| events1 << e }
2249
+ runner.subscribe { |e| events2 << e }
2250
+
2251
+ runner.run({ value: 10 })
2252
+
2253
+ assert_equal events1.size, events2.size
2254
+ end
2255
+ end
2256
+ ```
2257
+
2258
+ ### 18. `test/extensions/base_test.rb`
2259
+
2260
+ ```ruby
2261
+ # frozen_string_literal: true
2262
+
2263
+ require "test_helper"
2264
+
2265
+ class ExtensionsBaseTest < DurableWorkflowTest
2266
+ def test_extension_name_from_class
2267
+ ext = Class.new(DurableWorkflow::Extensions::Base)
2268
+ ext.instance_variable_set(:@name, "TestExtension")
2269
+
2270
+ assert_equal "testextension", ext.extension_name
2271
+ end
2272
+
2273
+ def test_extension_name_can_be_set
2274
+ ext = Class.new(DurableWorkflow::Extensions::Base)
2275
+ ext.extension_name = "custom"
2276
+
2277
+ assert_equal "custom", ext.extension_name
2278
+ end
2279
+
2280
+ def test_data_from_workflow
2281
+ ext = Class.new(DurableWorkflow::Extensions::Base)
2282
+ ext.extension_name = "test"
2283
+
2284
+ workflow = DurableWorkflow::Core::WorkflowDef.new(
2285
+ id: "wf",
2286
+ name: "WF",
2287
+ version: "1.0",
2288
+ steps: [],
2289
+ extensions: { test: { foo: "bar" } }
2290
+ )
2291
+
2292
+ data = ext.data_from(workflow)
2293
+
2294
+ assert_equal({ foo: "bar" }, data)
2295
+ end
2296
+
2297
+ def test_store_in_workflow
2298
+ ext = Class.new(DurableWorkflow::Extensions::Base)
2299
+ ext.extension_name = "test"
2300
+
2301
+ workflow = DurableWorkflow::Core::WorkflowDef.new(
2302
+ id: "wf",
2303
+ name: "WF",
2304
+ version: "1.0",
2305
+ steps: [],
2306
+ extensions: {}
2307
+ )
2308
+
2309
+ new_workflow = ext.store_in(workflow, { added: true })
2310
+
2311
+ assert_equal({}, workflow.extensions)
2312
+ assert_equal({ test: { added: true } }, new_workflow.extensions)
2313
+ end
2314
+
2315
+ def test_register_extension
2316
+ ext = Class.new(DurableWorkflow::Extensions::Base) do
2317
+ self.extension_name = "test_ext"
2318
+
2319
+ def self.register_configs; end
2320
+ def self.register_executors; end
2321
+ def self.register_parser_hooks; end
2322
+ end
2323
+
2324
+ DurableWorkflow::Extensions.register(:test_ext, ext)
2325
+
2326
+ assert DurableWorkflow::Extensions.loaded?(:test_ext)
2327
+ assert_equal ext, DurableWorkflow::Extensions[:test_ext]
2328
+ ensure
2329
+ DurableWorkflow::Extensions.extensions.delete(:test_ext)
2330
+ end
2331
+ end
2332
+ ```
2333
+
2334
+ ### 19. `Rakefile` (test task)
2335
+
2336
+ ```ruby
2337
+ # frozen_string_literal: true
2338
+
2339
+ require "bundler/gem_tasks"
2340
+ require "rake/testtask"
2341
+
2342
+ Rake::TestTask.new(:test) do |t|
2343
+ t.libs << "test"
2344
+ t.libs << "lib"
2345
+ t.test_files = FileList["test/**/*_test.rb"]
2346
+ t.warning = false
2347
+ end
2348
+
2349
+ task default: :test
2350
+ ```
2351
+
2352
+ ## Running Tests
2353
+
2354
+ ```bash
2355
+ # Run all tests
2356
+ bundle exec rake test
2357
+
2358
+ # Run specific test file
2359
+ bundle exec ruby -Ilib:test test/core/engine_test.rb
2360
+
2361
+ # Run with verbose output
2362
+ bundle exec rake test TESTOPTS="--verbose"
2363
+
2364
+ # Run specific test method
2365
+ bundle exec ruby -Ilib:test test/core/engine_test.rb -n test_run_simple_workflow
2366
+ ```
2367
+
2368
+ ## Acceptance Criteria
2369
+
2370
+ 1. All core types have full test coverage
2371
+ 2. Engine tests cover run, resume, halt, approval flows
2372
+ 3. Each executor has dedicated tests
2373
+ 4. Parser tests cover YAML parsing and hooks
2374
+ 5. Storage tests verify save/load/find/delete
2375
+ 6. Runner tests cover sync, async, and stream modes
2376
+ 7. Extension tests verify registration and hooks
2377
+ 8. All tests use Minitest (not RSpec)
2378
+ 9. Test helper provides Memory store for isolated tests