durable_workflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- metadata +275 -0
|
@@ -0,0 +1,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
|