igniter 0.3.1 → 0.4.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  3. data/examples/distributed_workflow.rb +52 -0
  4. data/lib/igniter/compiler/compiled_graph.rb +12 -0
  5. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  6. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  7. data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
  8. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  9. data/lib/igniter/compiler.rb +2 -0
  10. data/lib/igniter/contract.rb +59 -8
  11. data/lib/igniter/dsl/contract_builder.rb +42 -4
  12. data/lib/igniter/errors.rb +6 -1
  13. data/lib/igniter/integrations/llm/config.rb +69 -0
  14. data/lib/igniter/integrations/llm/context.rb +74 -0
  15. data/lib/igniter/integrations/llm/executor.rb +159 -0
  16. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  17. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  18. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  19. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  20. data/lib/igniter/integrations/llm.rb +59 -0
  21. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  22. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  23. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  24. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  25. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  26. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  27. data/lib/igniter/integrations/rails.rb +12 -0
  28. data/lib/igniter/model/await_node.rb +21 -0
  29. data/lib/igniter/model/remote_node.rb +26 -0
  30. data/lib/igniter/model.rb +2 -0
  31. data/lib/igniter/runtime/execution.rb +2 -2
  32. data/lib/igniter/runtime/input_validator.rb +5 -3
  33. data/lib/igniter/runtime/resolver.rb +43 -1
  34. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  35. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  36. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  37. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  38. data/lib/igniter/server/client.rb +123 -0
  39. data/lib/igniter/server/config.rb +27 -0
  40. data/lib/igniter/server/handlers/base.rb +105 -0
  41. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  42. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  43. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  44. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  45. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  46. data/lib/igniter/server/http_server.rb +109 -0
  47. data/lib/igniter/server/rack_app.rb +35 -0
  48. data/lib/igniter/server/registry.rb +56 -0
  49. data/lib/igniter/server/router.rb +75 -0
  50. data/lib/igniter/server.rb +67 -0
  51. data/lib/igniter/version.rb +1 -1
  52. data/lib/igniter.rb +4 -0
  53. metadata +36 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 659dfe833fdf98b7d1b446e08d02464545fd0fe9b61badd5cf8e7ca73beb6a1b
4
- data.tar.gz: 5af168ce8c6fad1c18dd0d9703281ea64062c3a6d3e954b12172f29210e12c5b
3
+ metadata.gz: 00ad08d39fff726f2d88cefe18758dca71dc7b6fbccce872199347e23bf86e9a
4
+ data.tar.gz: 42791bdf591fbab256bb32a7da1b37466317e0c190d3693830ee82885f3fe238
5
5
  SHA512:
6
- metadata.gz: 5865bc3fb30c137baa5bdf87b63f9023c95c50afa87701a052a47726d139ab6113ed52820f629d0b45de421247778d560133e2c2d2f5e756681a14af2cac0026
7
- data.tar.gz: de8fa4b7f92a566016f21d81acdbf9d5ec4f4f0fa458ac99b18f8c5cced124390276ea541a1f252067009fd4e3ccf688d97d239080b8995227fc697f0d5b9e6e
6
+ metadata.gz: 45034ac4e94ee4c1864a8ca5222505d2192c3cf4667c1ba6149161cf15079c7000a80c75e2dad6baafb79dfde0b9cb5657fd0ffd2d869f3600210ec3429268a9
7
+ data.tar.gz: 4f797eb253d945fd459f2ef29b64a4330616ecfcc0d8d6f1dfbbf98da24bfe090da6e6c28cda2ddf8203f71c52af98ec275cb157829febd032ab9846ccc883e8
@@ -0,0 +1,493 @@
1
+ # Distributed Contracts v1
2
+
3
+ ## Goal
4
+
5
+ `Distributed Contracts` extend `igniter` from in-process orchestration to long-running, event-driven workflow execution.
6
+
7
+ The feature should make distributed workflows:
8
+
9
+ - explicit
10
+ - correlation-aware
11
+ - resumable
12
+ - observable end to end
13
+ - compatible with the existing graph model
14
+
15
+ It should avoid pushing cross-system workflow state into ad hoc service objects, background jobs, or loosely related tables.
16
+
17
+ ## Problem Shape
18
+
19
+ Typical legacy pain looks like this:
20
+
21
+ - one business flow starts from an inbound request
22
+ - later signals arrive from different systems
23
+ - signals must be matched by business identity, not by process memory
24
+ - some steps complete immediately, others wait minutes or hours
25
+ - failures are not local, they are causal and temporal
26
+
27
+ Examples:
28
+
29
+ - vendor request -> callrail webhook -> ringcentral webhook -> operator match -> order creation -> billing decision -> invoice
30
+ - lead accepted now, enrichment later
31
+ - call started now, attribution resolved later
32
+
33
+ `igniter` already has useful building blocks:
34
+
35
+ - `pending`
36
+ - snapshot / restore
37
+ - store-backed execution
38
+ - token-based resume
39
+ - diagnostics and audit
40
+
41
+ Distributed Contracts v1 should formalize these into a workflow model.
42
+
43
+ ## Core Principles
44
+
45
+ ### Correlation first
46
+
47
+ The primary question is not:
48
+
49
+ - "which service should run next?"
50
+
51
+ It is:
52
+
53
+ - "which execution does this external signal belong to?"
54
+
55
+ Distributed execution must be resumable by stable business correlation keys.
56
+
57
+ ### External signals are graph-visible
58
+
59
+ Waiting for an external system should be represented in the graph model, not hidden inside generic polling or callback code.
60
+
61
+ ### Transport stays outside
62
+
63
+ Controllers, jobs, consumers, and webhook adapters remain outside `igniter`.
64
+
65
+ They should translate transport concerns into workflow operations such as:
66
+
67
+ - start workflow
68
+ - deliver signal
69
+ - resume execution
70
+
71
+ `igniter` should not become a message bus.
72
+
73
+ ## Proposed v1 Surface
74
+
75
+ ### Starting a distributed workflow
76
+
77
+ ```ruby
78
+ execution = CallLifecycleContract.start(
79
+ request_id: "req-123",
80
+ company_id: "42",
81
+ vendor_lead_id: "lead-77"
82
+ )
83
+ ```
84
+
85
+ This is conceptually similar to `new(...).resolve`, but v1 should expose a clearer workflow-oriented entry point.
86
+
87
+ ### Correlation metadata
88
+
89
+ ```ruby
90
+ class CallLifecycleContract < Igniter::Contract
91
+ correlate_by :request_id, :company_id, :vendor_lead_id
92
+ end
93
+ ```
94
+
95
+ This should define the business keys used to find the execution later.
96
+
97
+ Suggested v1 requirements:
98
+
99
+ - correlation keys are declared explicitly
100
+ - correlation values come from initial inputs and/or delivered signals
101
+ - the execution store can index and look up executions by correlation values
102
+
103
+ ### Awaiting an external signal
104
+
105
+ ```ruby
106
+ await :callrail_call,
107
+ event: :callrail_webhook_received
108
+
109
+ await :ringcentral_match,
110
+ event: :ringcentral_call_matched
111
+ ```
112
+
113
+ This is preferable to generic `defer` for distributed flows because it makes the waiting state explicit in the graph.
114
+
115
+ ### Delivering an external signal
116
+
117
+ ```ruby
118
+ CallLifecycleContract.deliver_event(
119
+ :callrail_webhook_received,
120
+ correlation: { request_id: "req-123", company_id: "42" },
121
+ payload: { call_id: "cr-1", started_at: "2026-03-19T10:00:00Z" }
122
+ )
123
+ ```
124
+
125
+ Expected behavior:
126
+
127
+ 1. Find the matching execution by correlation.
128
+ 2. Find a matching `await` node for the event.
129
+ 3. Bind the payload to that waiting node.
130
+ 4. Resume the workflow.
131
+
132
+ ### Diagnostics for waiting workflows
133
+
134
+ ```ruby
135
+ contract.diagnostics.to_h
136
+ ```
137
+
138
+ Should make waiting state explicit:
139
+
140
+ - current status
141
+ - known correlation keys
142
+ - expected external events
143
+ - last delivered event
144
+ - current blocked / waiting nodes
145
+
146
+ ## Scope of v1
147
+
148
+ Supported:
149
+
150
+ - explicit correlation keys
151
+ - explicit external wait nodes
152
+ - event delivery by correlation
153
+ - store-backed lookup and resume
154
+ - diagnostics for waiting state
155
+ - audit trail of delivered external signals
156
+
157
+ Not supported in v1:
158
+
159
+ - message bus integration
160
+ - retries / dead-letter routing
161
+ - event schema registry
162
+ - compensation / saga semantics
163
+ - multi-execution joins
164
+ - time-based wakeups or cron waits
165
+ - out-of-order signal reconciliation beyond simple correlation lookup
166
+
167
+ ## DSL
168
+
169
+ ### Basic shape
170
+
171
+ ```ruby
172
+ class CallLifecycleContract < Igniter::Contract
173
+ correlate_by :request_id, :company_id, :vendor_lead_id
174
+
175
+ define do
176
+ input :request_id, type: :string
177
+ input :company_id, type: :string
178
+ input :vendor_lead_id, type: :string
179
+
180
+ await :callrail_call, event: :callrail_webhook_received
181
+ await :ringcentral_match, event: :ringcentral_call_matched
182
+
183
+ aggregate :billing_context, with: %i[callrail_call ringcentral_match] do |callrail_call:, ringcentral_match:|
184
+ {
185
+ call_id: callrail_call[:call_id],
186
+ operator_id: ringcentral_match[:operator_id],
187
+ channel: ringcentral_match[:channel]
188
+ }
189
+ end
190
+
191
+ output :billing_context
192
+ end
193
+ end
194
+ ```
195
+
196
+ ### Signal delivery
197
+
198
+ ```ruby
199
+ CallLifecycleContract.deliver_event(
200
+ :ringcentral_call_matched,
201
+ correlation: {
202
+ request_id: "req-123",
203
+ company_id: "42",
204
+ vendor_lead_id: "lead-77"
205
+ },
206
+ payload: {
207
+ operator_id: 55,
208
+ channel: "paid_search"
209
+ }
210
+ )
211
+ ```
212
+
213
+ ## Why `await` instead of reusing generic `defer`
214
+
215
+ `defer` is useful as a low-level runtime primitive.
216
+
217
+ But distributed workflows need more explicit semantics:
218
+
219
+ - what event is being awaited
220
+ - how to resume it
221
+ - how to show it in diagnostics
222
+ - how to route external signals safely
223
+
224
+ `await` should be modeled as a graph primitive, not just a custom executor returning `DeferredResult`.
225
+
226
+ ## Runtime Semantics
227
+
228
+ ### Starting execution
229
+
230
+ 1. Create a normal execution.
231
+ 2. Persist workflow metadata:
232
+ - execution id
233
+ - graph name
234
+ - correlation keys
235
+ - workflow status
236
+ 3. Resolve until:
237
+ - success
238
+ - failure
239
+ - waiting on one or more `await` nodes
240
+
241
+ ### Await resolution
242
+
243
+ When an `await` node is reached:
244
+
245
+ 1. Mark node status as `waiting_for_event`
246
+ 2. Persist expected event metadata
247
+ 3. Stop downstream execution until signal arrives
248
+
249
+ ### Event delivery
250
+
251
+ When `deliver_event` is called:
252
+
253
+ 1. Resolve execution lookup by correlation
254
+ 2. Validate event name
255
+ 3. Match event to a waiting `await` node
256
+ 4. Persist audit metadata for the incoming signal
257
+ 5. Resume execution with delivered payload
258
+
259
+ ### Terminal states
260
+
261
+ Suggested workflow-level statuses:
262
+
263
+ - `running`
264
+ - `waiting`
265
+ - `succeeded`
266
+ - `failed`
267
+
268
+ Later possible additions:
269
+
270
+ - `cancelled`
271
+ - `timed_out`
272
+ - `abandoned`
273
+
274
+ ## Graph Model
275
+
276
+ Distributed Contracts v1 should introduce a dedicated node kind:
277
+
278
+ - `:await`
279
+
280
+ Suggested internal shape:
281
+
282
+ ```ruby
283
+ AwaitNode.new(
284
+ name: :ringcentral_match,
285
+ event: :ringcentral_call_matched
286
+ )
287
+ ```
288
+
289
+ This should not be modeled as generic compute.
290
+
291
+ ## Correlation Model
292
+
293
+ Correlation should be explicit and inspectable.
294
+
295
+ Suggested v1 API:
296
+
297
+ ```ruby
298
+ correlate_by :request_id, :company_id, :vendor_lead_id
299
+ ```
300
+
301
+ Suggested stored metadata:
302
+
303
+ ```ruby
304
+ {
305
+ request_id: "req-123",
306
+ company_id: "42",
307
+ vendor_lead_id: "lead-77"
308
+ }
309
+ ```
310
+
311
+ V1 should assume exact equality matching.
312
+
313
+ Future versions may allow:
314
+
315
+ - computed correlation values
316
+ - partial lookup
317
+ - secondary indexes
318
+
319
+ ## Store Requirements
320
+
321
+ The execution store needs more than snapshot persistence.
322
+
323
+ Distributed Contracts v1 should require:
324
+
325
+ - fetch by `execution_id`
326
+ - save snapshot
327
+ - delete snapshot
328
+ - lookup execution ids by correlation keys
329
+ - persist workflow metadata
330
+
331
+ Conceptually:
332
+
333
+ ```ruby
334
+ store.save(snapshot:, metadata:)
335
+ store.find_by_correlation(graph: "CallLifecycleContract", correlation: { ... })
336
+ ```
337
+
338
+ This probably means a new workflow-aware store interface, not just extending the current minimal snapshot store informally.
339
+
340
+ ## Compile-Time Validation
341
+
342
+ The compiler should validate:
343
+
344
+ - declared correlation keys exist as contract inputs
345
+ - `await` node names are unique
346
+ - awaited event names are unique within a graph unless explicitly allowed
347
+ - `await` nodes are valid dependencies for downstream nodes
348
+
349
+ Suggested v1 restriction:
350
+
351
+ - one `await` node per event name within a contract
352
+
353
+ This keeps signal delivery unambiguous.
354
+
355
+ ## Runtime Validation
356
+
357
+ At runtime, delivery should validate:
358
+
359
+ - the target execution exists
360
+ - the execution belongs to the expected contract graph
361
+ - the event is currently awaited
362
+ - the same awaited signal is not delivered twice unless idempotency policy allows it
363
+
364
+ Suggested runtime errors:
365
+
366
+ - `Igniter::WorkflowNotFoundError`
367
+ - `Igniter::AwaitedEventMismatchError`
368
+ - `Igniter::DuplicateSignalError`
369
+
370
+ ## Events and Audit
371
+
372
+ Distributed Contracts v1 should add workflow-aware events.
373
+
374
+ Suggested additions:
375
+
376
+ - `workflow_started`
377
+ - `workflow_waiting`
378
+ - `workflow_resumed`
379
+ - `external_event_received`
380
+ - `await_satisfied`
381
+
382
+ Suggested payload for incoming external signal:
383
+
384
+ ```ruby
385
+ {
386
+ event: :ringcentral_call_matched,
387
+ correlation: {
388
+ request_id: "req-123",
389
+ company_id: "42",
390
+ vendor_lead_id: "lead-77"
391
+ }
392
+ }
393
+ ```
394
+
395
+ Audit should make it possible to answer:
396
+
397
+ - which external events were received
398
+ - in what order
399
+ - which waiting node they satisfied
400
+ - why the workflow is still blocked
401
+
402
+ ## Introspection
403
+
404
+ ### Graph
405
+
406
+ `await` nodes should render distinctly from compute, branch, collection, and composition.
407
+
408
+ The graph should show:
409
+
410
+ - awaited event name
411
+ - correlation keys at the workflow level
412
+
413
+ ### Plan
414
+
415
+ Before execution:
416
+
417
+ - `await` nodes look like normal pending nodes
418
+
419
+ When waiting:
420
+
421
+ - plan should show explicit waiting state
422
+ - ideally also the awaited event name
423
+
424
+ ### Diagnostics
425
+
426
+ Diagnostics should surface:
427
+
428
+ - workflow status
429
+ - correlation keys
430
+ - awaited events
431
+ - satisfied events
432
+ - last external event
433
+ - blocked nodes
434
+
435
+ Example conceptual shape:
436
+
437
+ ```ruby
438
+ {
439
+ status: :waiting,
440
+ correlation: {
441
+ request_id: "req-123",
442
+ company_id: "42"
443
+ },
444
+ waiting_on: [
445
+ { node: :ringcentral_match, event: :ringcentral_call_matched }
446
+ ],
447
+ last_external_event: :callrail_webhook_received
448
+ }
449
+ ```
450
+
451
+ ## Relation to Existing Pending Support
452
+
453
+ Distributed Contracts v1 should build on existing pending/store/resume support instead of replacing it.
454
+
455
+ Conceptually:
456
+
457
+ - `await` is a higher-level workflow primitive
458
+ - internally it may still use pending state and resume mechanics
459
+ - `deliver_event` is a safer, domain-oriented wrapper over generic token resume
460
+
461
+ This keeps the runtime coherent and incremental.
462
+
463
+ ## Recommended Architectural Pattern
464
+
465
+ Do not start with one giant distributed contract from request to invoice.
466
+
467
+ Prefer stage-oriented workflow design:
468
+
469
+ - `InboundLeadContract`
470
+ - `CallAttributionContract`
471
+ - `OrderBillingContract`
472
+ - optional orchestration shell above them
473
+
474
+ This keeps contracts:
475
+
476
+ - understandable
477
+ - diagnosable
478
+ - evolvable
479
+
480
+ ## Future Extensions
481
+
482
+ Possible later additions:
483
+
484
+ - event payload schema validation
485
+ - timeout / expiry policies on `await`
486
+ - idempotency keys for signal delivery
487
+ - compensation hooks
488
+ - `await_any` / `await_all`
489
+ - workflow versioning and migration
490
+ - cross-workflow linking
491
+ - visual workflow tracing
492
+
493
+ These should not be part of v1.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.join(__dir__, "../lib")
4
+ require "igniter"
5
+
6
+ store = Igniter::Runtime::Stores::MemoryStore.new
7
+
8
+ class LeadWorkflow < Igniter::Contract
9
+ correlate_by :request_id, :company_id
10
+
11
+ define do
12
+ input :request_id
13
+ input :company_id
14
+
15
+ await :crm_data, event: :crm_webhook_received
16
+ await :billing_data, event: :billing_data_fetched
17
+
18
+ aggregate :report, with: %i[crm_data billing_data] do |crm_data:, billing_data:|
19
+ { crm: crm_data, billing: billing_data }
20
+ end
21
+
22
+ output :report
23
+ end
24
+ end
25
+
26
+ puts "==> Starting execution..."
27
+ execution = LeadWorkflow.start(
28
+ { request_id: "req-1", company_id: "co-42" },
29
+ store: store
30
+ )
31
+ puts "pending? #{execution.pending?}"
32
+
33
+ puts "\n==> Delivering CRM event..."
34
+ execution = LeadWorkflow.deliver_event(
35
+ :crm_webhook_received,
36
+ correlation: { request_id: "req-1", company_id: "co-42" },
37
+ payload: { name: "Acme Corp", tier: "enterprise" },
38
+ store: store
39
+ )
40
+ puts "still pending? #{execution.pending?}"
41
+
42
+ puts "\n==> Delivering billing event..."
43
+ execution = LeadWorkflow.deliver_event(
44
+ :billing_data_fetched,
45
+ correlation: { request_id: "req-1", company_id: "co-42" },
46
+ payload: { plan: "pro", mrr: 500 },
47
+ store: store
48
+ )
49
+
50
+ puts "\n==> Final result:"
51
+ puts "success? #{execution.success?}"
52
+ puts "report: #{execution.result.report.inspect}"
@@ -49,6 +49,10 @@ module Igniter
49
49
  raise KeyError, "Unknown dependency '#{name}'"
50
50
  end
51
51
 
52
+ def await_nodes
53
+ @nodes.select { |n| n.kind == :await }
54
+ end
55
+
52
56
  def to_h
53
57
  {
54
58
  name: name,
@@ -80,6 +84,7 @@ module Igniter
80
84
  base[:mode] = node.mode
81
85
  base[:mapper] = node.input_mapper.to_s if node.input_mapper?
82
86
  end
87
+ base[:event] = node.event_name if node.kind == :await
83
88
  base
84
89
  end,
85
90
  outputs: outputs.map do |output|
@@ -117,6 +122,13 @@ module Igniter
117
122
  metadata: node.metadata.reject { |key, _| key == :source_location }
118
123
  }
119
124
  end,
125
+ awaits: nodes.select { |node| node.kind == :await }.map do |node|
126
+ {
127
+ name: node.name,
128
+ event: node.event_name,
129
+ metadata: node.metadata.reject { |key, _| key == :source_location }
130
+ }
131
+ end,
120
132
  branches: nodes.select { |node| node.kind == :branch }.map do |node|
121
133
  {
122
134
  name: node.name,
@@ -8,7 +8,9 @@ module Igniter
8
8
  Validators::OutputsValidator,
9
9
  Validators::DependenciesValidator,
10
10
  Validators::TypeCompatibilityValidator,
11
- Validators::CallableValidator
11
+ Validators::CallableValidator,
12
+ Validators::AwaitValidator,
13
+ Validators::RemoteValidator
12
14
  ].freeze
13
15
 
14
16
  def self.call(context, validators: DEFAULT_VALIDATORS)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ module Validators
6
+ class AwaitValidator
7
+ def self.call(context)
8
+ new(context).call
9
+ end
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def call
16
+ await_nodes = @context.runtime_nodes.select { |n| n.kind == :await }
17
+ return if await_nodes.empty?
18
+
19
+ validate_correlation_keys_as_inputs!(await_nodes)
20
+ validate_unique_event_names!(await_nodes)
21
+ end
22
+
23
+ private
24
+
25
+ def validate_correlation_keys_as_inputs!(await_nodes) # rubocop:disable Metrics/AbcSize
26
+ correlation_keys = @context.graph.metadata[:correlation_keys] || []
27
+ return if correlation_keys.empty?
28
+
29
+ input_names = @context.runtime_nodes.select { |n| n.kind == :input }.map(&:name)
30
+ missing = correlation_keys.reject { |key| input_names.include?(key.to_sym) }
31
+ return if missing.empty?
32
+
33
+ raise @context.validation_error(
34
+ await_nodes.first,
35
+ "Correlation keys #{missing.inspect} must be declared as inputs"
36
+ )
37
+ end
38
+
39
+ def validate_unique_event_names!(await_nodes)
40
+ event_names = await_nodes.map(&:event_name)
41
+ duplicates = event_names.select { |e| event_names.count(e) > 1 }.uniq
42
+ return if duplicates.empty?
43
+
44
+ node = await_nodes.find { |n| duplicates.include?(n.event_name) }
45
+ raise @context.validation_error(
46
+ node,
47
+ "Duplicate await event names: #{duplicates.inspect}"
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -12,8 +12,10 @@ module Igniter
12
12
  @context = context
13
13
  end
14
14
 
15
- def call
15
+ def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
16
16
  @context.runtime_nodes.each do |node|
17
+ next if node.kind == :await
18
+
17
19
  validate_composition_node!(node) if node.kind == :composition
18
20
  validate_branch_node!(node) if node.kind == :branch
19
21
  validate_collection_node!(node) if node.kind == :collection
@@ -40,6 +42,7 @@ module Igniter
40
42
  end
41
43
 
42
44
  validate_composition_input_mapping!(node, contract_class.compiled_graph)
45
+ validate_composition_cycle!(node)
43
46
  end
44
47
 
45
48
  def validate_composition_input_mapping!(node, child_graph)
@@ -126,6 +129,43 @@ module Igniter
126
129
  )
127
130
  end
128
131
 
132
+ def validate_composition_cycle!(node)
133
+ child_contract = node.contract_class
134
+ return unless child_contract.respond_to?(:compiled_graph) && child_contract.compiled_graph
135
+
136
+ current_name = @context.graph.name
137
+ # Skip anonymous contracts to avoid false positives when multiple
138
+ # anonymous contracts share the same name "AnonymousContract"
139
+ return if current_name == "AnonymousContract"
140
+
141
+ validate_direct_cycle!(node, child_contract, current_name)
142
+ validate_grandchild_cycles!(node, child_contract, current_name)
143
+ end
144
+
145
+ def validate_direct_cycle!(node, child_contract, current_name)
146
+ return unless child_contract.compiled_graph.name == current_name
147
+
148
+ raise @context.validation_error(
149
+ node,
150
+ "Composition cycle: '#{node.name}' composes '#{child_contract.name}' " \
151
+ "which is the same contract ('#{current_name}')"
152
+ )
153
+ end
154
+
155
+ def validate_grandchild_cycles!(node, child_contract, current_name) # rubocop:disable Metrics/AbcSize
156
+ child_contract.compiled_graph.nodes.select { |n| n.kind == :composition }.each do |grandchild|
157
+ next unless grandchild.contract_class.respond_to?(:compiled_graph)
158
+ next unless grandchild.contract_class.compiled_graph
159
+ next unless grandchild.contract_class.compiled_graph.name == current_name
160
+
161
+ raise @context.validation_error(
162
+ node,
163
+ "Composition cycle: '#{node.name}' -> '#{child_contract.name}' -> " \
164
+ "'#{grandchild.contract_class.name}' loops back to '#{current_name}'"
165
+ )
166
+ end
167
+ end
168
+
129
169
  def validate_collection_node!(node)
130
170
  unless node.contract_class.is_a?(Class) && node.contract_class <= Igniter::Contract
131
171
  raise @context.validation_error(node, "Collection '#{node.name}' must reference an Igniter::Contract subclass")