igniter 0.4.0 → 0.4.5

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/incremental.rb +142 -0
  13. data/examples/invariants.rb +179 -0
  14. data/examples/order_pipeline.rb +163 -0
  15. data/examples/provenance.rb +122 -0
  16. data/examples/saga.rb +110 -0
  17. data/lib/igniter/agent/mailbox.rb +96 -0
  18. data/lib/igniter/agent/message.rb +21 -0
  19. data/lib/igniter/agent/ref.rb +86 -0
  20. data/lib/igniter/agent/runner.rb +129 -0
  21. data/lib/igniter/agent/state_holder.rb +23 -0
  22. data/lib/igniter/agent.rb +155 -0
  23. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  24. data/lib/igniter/differential/divergence.rb +29 -0
  25. data/lib/igniter/differential/formatter.rb +96 -0
  26. data/lib/igniter/differential/report.rb +86 -0
  27. data/lib/igniter/differential/runner.rb +130 -0
  28. data/lib/igniter/differential.rb +51 -0
  29. data/lib/igniter/dsl/contract_builder.rb +32 -0
  30. data/lib/igniter/effect.rb +91 -0
  31. data/lib/igniter/effect_registry.rb +78 -0
  32. data/lib/igniter/errors.rb +11 -1
  33. data/lib/igniter/execution_report/builder.rb +54 -0
  34. data/lib/igniter/execution_report/formatter.rb +50 -0
  35. data/lib/igniter/execution_report/node_entry.rb +24 -0
  36. data/lib/igniter/execution_report/report.rb +65 -0
  37. data/lib/igniter/execution_report.rb +32 -0
  38. data/lib/igniter/extensions/differential.rb +114 -0
  39. data/lib/igniter/extensions/execution_report.rb +27 -0
  40. data/lib/igniter/extensions/incremental.rb +50 -0
  41. data/lib/igniter/extensions/invariants.rb +116 -0
  42. data/lib/igniter/extensions/provenance.rb +45 -0
  43. data/lib/igniter/extensions/saga.rb +74 -0
  44. data/lib/igniter/incremental/formatter.rb +81 -0
  45. data/lib/igniter/incremental/result.rb +69 -0
  46. data/lib/igniter/incremental/tracker.rb +108 -0
  47. data/lib/igniter/incremental.rb +50 -0
  48. data/lib/igniter/integrations/agents.rb +18 -0
  49. data/lib/igniter/invariant.rb +50 -0
  50. data/lib/igniter/model/effect_node.rb +37 -0
  51. data/lib/igniter/model.rb +1 -0
  52. data/lib/igniter/property_testing/formatter.rb +66 -0
  53. data/lib/igniter/property_testing/generators.rb +115 -0
  54. data/lib/igniter/property_testing/result.rb +45 -0
  55. data/lib/igniter/property_testing/run.rb +43 -0
  56. data/lib/igniter/property_testing/runner.rb +47 -0
  57. data/lib/igniter/property_testing.rb +64 -0
  58. data/lib/igniter/provenance/builder.rb +97 -0
  59. data/lib/igniter/provenance/lineage.rb +82 -0
  60. data/lib/igniter/provenance/node_trace.rb +65 -0
  61. data/lib/igniter/provenance/text_formatter.rb +70 -0
  62. data/lib/igniter/provenance.rb +29 -0
  63. data/lib/igniter/registry.rb +67 -0
  64. data/lib/igniter/runtime/cache.rb +35 -6
  65. data/lib/igniter/runtime/execution.rb +8 -2
  66. data/lib/igniter/runtime/node_state.rb +7 -2
  67. data/lib/igniter/runtime/resolver.rb +84 -15
  68. data/lib/igniter/saga/compensation.rb +31 -0
  69. data/lib/igniter/saga/compensation_record.rb +20 -0
  70. data/lib/igniter/saga/executor.rb +85 -0
  71. data/lib/igniter/saga/formatter.rb +49 -0
  72. data/lib/igniter/saga/result.rb +47 -0
  73. data/lib/igniter/saga.rb +56 -0
  74. data/lib/igniter/stream_loop.rb +80 -0
  75. data/lib/igniter/supervisor.rb +167 -0
  76. data/lib/igniter/version.rb +1 -1
  77. data/lib/igniter.rb +10 -0
  78. metadata +63 -1
data/docs/SERVER_V1.md ADDED
@@ -0,0 +1,313 @@
1
+ # igniter-server v1
2
+
3
+ igniter-server turns any Igniter contract into an HTTP service. Multiple server nodes can call
4
+ each other using the `remote:` DSL, enabling distributed multi-node architectures with
5
+ compile-time validated cross-node contracts.
6
+
7
+ ## Quick Start
8
+
9
+ ### Ruby API
10
+
11
+ ```ruby
12
+ require "igniter/server"
13
+
14
+ class ScoringContract < Igniter::Contract
15
+ define do
16
+ input :value
17
+ compute :score, depends_on: :value, call: ->(value:) { value * 1.5 }
18
+ output :score
19
+ end
20
+ end
21
+
22
+ Igniter::Server.configure do |c|
23
+ c.host = "0.0.0.0"
24
+ c.port = 4567
25
+ c.register "ScoringContract", ScoringContract
26
+ end
27
+
28
+ Igniter::Server.start # blocking
29
+ ```
30
+
31
+ ### CLI
32
+
33
+ ```bash
34
+ # Start server, loading contracts from a file
35
+ igniter-server start --port 4567 --require ./contracts.rb
36
+
37
+ # With a config block for additional setup
38
+ igniter-server start --port 4567 --require ./contracts.rb --config ./server_config.rb
39
+ ```
40
+
41
+ ### Rack / Puma (production)
42
+
43
+ ```ruby
44
+ # config.ru
45
+ require "igniter/server"
46
+ require_relative "contracts"
47
+
48
+ Igniter::Server.configure do |c|
49
+ c.register "ScoringContract", ScoringContract
50
+ c.store = Igniter::Runtime::Stores::MemoryStore.new
51
+ end
52
+
53
+ run Igniter::Server.rack_app
54
+ ```
55
+
56
+ ```bash
57
+ bundle exec puma config.ru -p 4567
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Configuration
63
+
64
+ ```ruby
65
+ Igniter::Server.configure do |c|
66
+ c.host = "0.0.0.0" # bind address (default: "0.0.0.0")
67
+ c.port = 4567 # TCP port (default: 4567)
68
+ c.store = my_store # execution store for distributed contracts
69
+ c.register "Name", MyClass # register a contract
70
+ c.contracts = { # bulk registration
71
+ "ContractA" => ContractA,
72
+ "ContractB" => ContractB
73
+ }
74
+ end
75
+ ```
76
+
77
+ Reset configuration (useful in tests):
78
+
79
+ ```ruby
80
+ Igniter::Server.reset!
81
+ ```
82
+
83
+ ---
84
+
85
+ ## REST API Reference
86
+
87
+ All responses use `Content-Type: application/json`.
88
+
89
+ ### `POST /v1/contracts/:name/execute`
90
+
91
+ Execute a contract synchronously. For distributed contracts (`correlate_by`), this is
92
+ equivalent to `Contract.start` and returns a `pending` response.
93
+
94
+ **Request body:**
95
+ ```json
96
+ { "inputs": { "value": 42 } }
97
+ ```
98
+
99
+ **Responses:**
100
+
101
+ ```json
102
+ // Succeeded
103
+ { "execution_id": "uuid", "status": "succeeded", "outputs": { "score": 63.0 } }
104
+
105
+ // Failed node
106
+ { "execution_id": "uuid", "status": "failed",
107
+ "error": { "type": "Igniter::ResolutionError", "message": "...", "node": "score" } }
108
+
109
+ // Pending (distributed contract waiting for events)
110
+ { "execution_id": "uuid", "status": "pending", "waiting_for": ["data_received"] }
111
+ ```
112
+
113
+ **HTTP status codes:** `200` for all execution outcomes (including failed), `404` if contract
114
+ not registered, `422` for Igniter errors, `500` for unexpected errors.
115
+
116
+ ---
117
+
118
+ ### `POST /v1/contracts/:name/events`
119
+
120
+ Deliver an event to a distributed contract execution.
121
+
122
+ **Request body:**
123
+ ```json
124
+ {
125
+ "event": "data_received",
126
+ "correlation": { "request_id": "r1" },
127
+ "payload": { "data": "value" }
128
+ }
129
+ ```
130
+
131
+ **Response:** same shape as `/execute`.
132
+
133
+ ---
134
+
135
+ ### `GET /v1/executions/:id`
136
+
137
+ Poll the status of a previously started execution.
138
+
139
+ **Response:**
140
+ ```json
141
+ { "execution_id": "uuid", "status": "pending", "waiting_for": ["data_received"] }
142
+ ```
143
+
144
+ Returns `404` if the execution is not found in the configured store.
145
+
146
+ ---
147
+
148
+ ### `GET /v1/health`
149
+
150
+ Health check endpoint.
151
+
152
+ **Response:**
153
+ ```json
154
+ {
155
+ "status": "ok",
156
+ "contracts": ["ScoringContract", "PipelineContract"],
157
+ "store": "MemoryStore",
158
+ "pending": 3
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ### `GET /v1/contracts`
165
+
166
+ List registered contracts with their input and output names.
167
+
168
+ **Response:**
169
+ ```json
170
+ [
171
+ { "name": "ScoringContract", "inputs": ["value"], "outputs": ["score"] }
172
+ ]
173
+ ```
174
+
175
+ ---
176
+
177
+ ## `remote:` DSL — Cross-Node Contract Composition
178
+
179
+ Call a contract on a remote igniter-server as a node inside a local graph:
180
+
181
+ ```ruby
182
+ require "igniter/server"
183
+
184
+ class OrchestratorContract < Igniter::Contract
185
+ define do
186
+ input :data
187
+
188
+ # Calls ScoringContract on a remote node over HTTP
189
+ remote :scored,
190
+ contract: "ScoringContract",
191
+ node: "http://scoring-service:4568",
192
+ inputs: { value: :data },
193
+ timeout: 10 # seconds (default: 30)
194
+
195
+ compute :label, depends_on: :scored do |scored:|
196
+ scored[:score] > 50 ? "high" : "low"
197
+ end
198
+
199
+ output :label
200
+ end
201
+ end
202
+ ```
203
+
204
+ The `remote:` node is validated at compile time:
205
+ - `node:` must start with `http://` or `https://`
206
+ - `contract:` must be a non-empty string
207
+ - All keys in `inputs:` must reference nodes that exist in the local graph
208
+
209
+ At runtime, if the remote service is unreachable or the remote contract fails,
210
+ an `Igniter::ResolutionError` is raised and propagates like any other node failure.
211
+
212
+ **Note:** `require "igniter/server"` is required to load `Igniter::Server::Client`.
213
+ Without it, any contract with a `remote:` node will raise at resolution time.
214
+
215
+ ---
216
+
217
+ ## HTTP Client
218
+
219
+ Use `Igniter::Server::Client` directly to call a remote service from application code:
220
+
221
+ ```ruby
222
+ require "igniter/server"
223
+
224
+ client = Igniter::Server::Client.new("http://localhost:4568", timeout: 30)
225
+
226
+ # Execute a contract
227
+ response = client.execute("ScoringContract", inputs: { value: 42 })
228
+ # => { status: :succeeded, execution_id: "uuid", outputs: { score: 63.0 } }
229
+
230
+ # Deliver an event
231
+ client.deliver_event("LeadWorkflow",
232
+ event: "data_received",
233
+ correlation: { request_id: "r1" },
234
+ payload: { value: "hello" })
235
+
236
+ # Poll execution status
237
+ client.status("uuid")
238
+ # => { status: :pending, waiting_for: ["data_received"] }
239
+
240
+ # Health check
241
+ client.health
242
+ # => { status: "ok", contracts: [...], pending: 0 }
243
+ ```
244
+
245
+ Errors:
246
+ - `Igniter::Server::Client::ConnectionError` — network unreachable (wraps `Errno::ECONNREFUSED`, `SocketError`, etc.)
247
+ - `Igniter::Server::Client::RemoteError` — HTTP 4xx/5xx response from the server
248
+
249
+ ---
250
+
251
+ ## Multi-Node Architecture
252
+
253
+ ```
254
+ ┌──────────────────────────┐ ┌──────────────────────────┐
255
+ │ Orchestrator :4567 │ │ Scoring Service :4568 │
256
+ │ │ │ │
257
+ │ OrchestratorContract │ │ ScoringContract │
258
+ │ remote :scored, ────┼──HTTP──▶│ POST /v1/contracts/ │
259
+ │ node: "…:4568" │ │ ScoringContract/ │
260
+ │ │◀────────┤ execute │
261
+ │ contract.result.label │ │ ← { status, outputs } │
262
+ └──────────────────────────┘ └──────────────────────────┘
263
+ ```
264
+
265
+ Each node is an independent Ruby process. The orchestrator's graph is validated
266
+ at load time — if `ScoringContract`'s URL is malformed, it fails at compile time,
267
+ not at the first HTTP call.
268
+
269
+ ---
270
+
271
+ ## Security
272
+
273
+ igniter-server ships with no built-in authentication. For production deployments:
274
+
275
+ - Place the server behind a reverse proxy (nginx, Caddy, AWS ALB) that handles TLS and auth
276
+ - Use network-level access controls (VPC, security groups, firewall rules)
277
+ - Add Rack middleware for API key validation when using `rack_app`
278
+
279
+ Example with Rack middleware:
280
+
281
+ ```ruby
282
+ # config.ru
283
+ require "igniter/server"
284
+
285
+ app = Igniter::Server.rack_app
286
+
287
+ auth_app = ->(env) {
288
+ return [401, {}, ["Unauthorized"]] unless env["HTTP_X_API_KEY"] == ENV["API_KEY"]
289
+ app.call(env)
290
+ }
291
+
292
+ run auth_app
293
+ ```
294
+
295
+ ---
296
+
297
+ ## Store Configuration
298
+
299
+ For stateless contracts (no `await` or `correlate_by`), the default `MemoryStore` is fine.
300
+
301
+ For distributed contracts that survive process restarts, use an external store:
302
+
303
+ ```ruby
304
+ Igniter::Server.configure do |c|
305
+ # Redis (requires redis gem)
306
+ c.store = Igniter::Runtime::Stores::RedisStore.new(ENV["REDIS_URL"])
307
+
308
+ # ActiveRecord (requires Rails + activerecord gem)
309
+ c.store = Igniter::Runtime::Stores::ActiveRecordStore.new
310
+ end
311
+ ```
312
+
313
+ See [Store Adapters](STORE_ADAPTERS.md) for full reference.
data/examples/README.md CHANGED
@@ -199,6 +199,135 @@ status_route_branch=CallConnected
199
199
  child_collection_summary={:mode=>:collect, :total=>3, ...}
200
200
  ```
201
201
 
202
+ ### `order_pipeline.rb`
203
+
204
+ Run:
205
+
206
+ ```bash
207
+ ruby examples/order_pipeline.rb
208
+ ```
209
+
210
+ Shows:
211
+
212
+ - `guard` — abort early when a precondition is not met
213
+ - `collection` — fan-out via `LineItemContract` per line item
214
+ - `branch` — route to domestic or international shipping strategy
215
+ - `export` — lift branch outputs (`shipping_cost`, `eta`) to the parent graph
216
+ - end-to-end pipeline: items → subtotal → shipping → grand total
217
+
218
+ Expected output shape:
219
+
220
+ ```text
221
+ === US Order ===
222
+ items_summary={:mode=>:collect, :total=>3, :succeeded=>3, :failed=>0, :status=>:succeeded}
223
+ order_subtotal=199.96
224
+ shipping_cost=0.0
225
+ eta=2-3 business days
226
+ grand_total=199.96
227
+
228
+ === International Order (DE) ===
229
+ shipping_cost=29.99
230
+ eta=7-14 business days
231
+ grand_total=229.95
232
+
233
+ === Out of Stock ===
234
+ error=Order cannot be placed: items are out of stock
235
+ ```
236
+
237
+ ### `distributed_server.rb`
238
+
239
+ Run:
240
+
241
+ ```bash
242
+ ruby examples/distributed_server.rb
243
+ ```
244
+
245
+ Shows:
246
+
247
+ - `correlate_by` — tag a contract class with correlation keys
248
+ - `Contract.start` — launch an execution that suspends at `await` nodes
249
+ - `Contract.deliver_event` — resume a suspended execution with an event payload
250
+ - `on_success` — callback that fires when the graph completes
251
+ - full lifecycle: submit → screening event → manager event → decision
252
+
253
+ Expected output shape:
254
+
255
+ ```text
256
+ === Step 1: Application submitted ===
257
+ pending=true
258
+ waiting_for=[:screening_completed]
259
+
260
+ === Step 2: Background screening completed ===
261
+ still_pending=true
262
+
263
+ === Step 3: Manager review completed ===
264
+ [callback] Decision reached: HIRED
265
+
266
+ === Final result ===
267
+ success=true
268
+ decision={:status=>:hired, :note=>"Excellent system design skills"}
269
+ ```
270
+
271
+ ### `llm/tool_use.rb`
272
+
273
+ Run:
274
+
275
+ ```bash
276
+ ruby examples/llm/tool_use.rb
277
+ ```
278
+
279
+ Shows:
280
+
281
+ - chained LLM compute nodes (`classify → assess priority → draft response`)
282
+ - tool declaration with the class-level `tools` method
283
+ - conversation context with `Igniter::LLM::Context` for multi-turn messages
284
+ - mock provider so the example runs offline without an API key
285
+
286
+ Expected output shape (with mock provider):
287
+
288
+ ```text
289
+ === Feedback Triage Pipeline ===
290
+ category=category: bug_report
291
+ priority=priority: high
292
+ response=We have logged this issue and will address it in the next release.
293
+
294
+ --- Diagnostics ---
295
+ ...
296
+ ```
297
+
298
+ ### `agents.rb`
299
+
300
+ Run:
301
+
302
+ ```bash
303
+ ruby examples/agents.rb
304
+ ```
305
+
306
+ Shows:
307
+
308
+ - `Igniter::Agent` — stateful message-driven actor with `on` handlers and `schedule` timers
309
+ - `Igniter::Supervisor` — one_for_one supervision with restart budget
310
+ - `Igniter::Registry` — thread-safe lookup by name
311
+ - `Igniter::StreamLoop` — continuous contract tick-loop with hot-swap inputs
312
+
313
+ Expected output shape:
314
+
315
+ ```text
316
+ === Supervised agents ===
317
+ counter=8
318
+ after_reset=10
319
+ history=[10]
320
+
321
+ === Registry lookup ===
322
+ named_counter=42
323
+
324
+ === Stream loop ===
325
+ statuses_sample=[:alert, :normal]
326
+
327
+ log_entries=1
328
+ done=true
329
+ ```
330
+
202
331
  ## Validation
203
332
 
204
333
  These scripts are exercised by [example_scripts_spec.rb](/Users/alex/dev/hotfix/igniter/spec/igniter/example_scripts_spec.rb), so the documented commands and outputs stay aligned with the code.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Agents — stateful actors with supervision and continuous contract loops
4
+ #
5
+ # Demonstrates:
6
+ # - Igniter::Agent — stateful message-driven actor
7
+ # - Igniter::Supervisor — one_for_one supervision with restart budget
8
+ # - Igniter::Registry — looking up agents by name
9
+ # - Igniter::StreamLoop — continuous contract tick-loop
10
+ #
11
+ # Run: ruby examples/agents.rb
12
+
13
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
14
+ require "igniter"
15
+ require "igniter/integrations/agents"
16
+
17
+ # ── Contract used by the stream loop ─────────────────────────────────────────
18
+
19
+ class ThresholdContract < Igniter::Contract
20
+ define do
21
+ input :value, type: :numeric
22
+ input :threshold, type: :numeric
23
+
24
+ compute :status, depends_on: %i[value threshold] do |value:, threshold:|
25
+ value > threshold ? :alert : :normal
26
+ end
27
+
28
+ output :status
29
+ end
30
+ end
31
+
32
+ # ── Counter Agent ─────────────────────────────────────────────────────────────
33
+
34
+ class CounterAgent < Igniter::Agent
35
+ initial_state counter: 0, history: []
36
+
37
+ on :increment do |state:, payload:, **|
38
+ by = payload.fetch(:by, 1)
39
+ new_count = state[:counter] + by
40
+ state.merge(
41
+ counter: new_count,
42
+ history: (state[:history] + [new_count]).last(10)
43
+ )
44
+ end
45
+
46
+ on :reset do |state:, **|
47
+ state.merge(counter: 0, history: [])
48
+ end
49
+
50
+ # Returns the current count to sync callers
51
+ on :count do |state:, **|
52
+ state[:counter]
53
+ end
54
+
55
+ # Returns the last N history entries
56
+ on :history do |state:, payload:, **|
57
+ state[:history].last(payload.fetch(:n, 5))
58
+ end
59
+ end
60
+
61
+ # ── Logger Agent ──────────────────────────────────────────────────────────────
62
+
63
+ class LoggerAgent < Igniter::Agent
64
+ initial_state entries: []
65
+
66
+ on :log do |state:, payload:, **|
67
+ ts = Time.now.strftime("%H:%M:%S")
68
+ entry = "[#{ts}] #{payload[:message]}"
69
+ state.merge(entries: (state[:entries] + [entry]).last(100))
70
+ end
71
+
72
+ on :dump do |state:, **|
73
+ state[:entries]
74
+ end
75
+ end
76
+
77
+ # ── Supervisor ────────────────────────────────────────────────────────────────
78
+
79
+ class AppSupervisor < Igniter::Supervisor
80
+ strategy :one_for_one
81
+ max_restarts 3, within: 10
82
+
83
+ children do |c|
84
+ c.worker :counter, CounterAgent
85
+ c.worker :logger, LoggerAgent
86
+ end
87
+ end
88
+
89
+ # ── Run: Supervised agents ────────────────────────────────────────────────────
90
+
91
+ puts "=== Supervised agents ==="
92
+
93
+ sup = AppSupervisor.start
94
+ counter = sup.child(:counter)
95
+ logger = sup.child(:logger)
96
+
97
+ counter.send(:increment, by: 5)
98
+ counter.send(:increment, by: 3)
99
+
100
+ total = counter.call(:count)
101
+ puts "counter=#{total}"
102
+
103
+ logger.send(:log, message: "Counter reached #{total}")
104
+
105
+ counter.send(:reset)
106
+ counter.send(:increment, by: 10)
107
+
108
+ new_total = counter.call(:count)
109
+ puts "after_reset=#{new_total}"
110
+
111
+ hist = counter.call(:history, { n: 5 })
112
+ puts "history=#{hist.inspect}"
113
+
114
+ # ── Run: Registry ─────────────────────────────────────────────────────────────
115
+
116
+ puts "\n=== Registry lookup ==="
117
+
118
+ named_ref = CounterAgent.start(name: :named_counter)
119
+ Igniter::Registry.find(:named_counter).send(:increment, by: 42)
120
+ puts "named_counter=#{named_ref.call(:count)}"
121
+ named_ref.stop
122
+ Igniter::Registry.unregister(:named_counter)
123
+
124
+ # ── Run: StreamLoop ───────────────────────────────────────────────────────────
125
+
126
+ puts "\n=== Stream loop ==="
127
+
128
+ statuses = []
129
+
130
+ stream = Igniter::StreamLoop.new(
131
+ contract: ThresholdContract,
132
+ tick_interval: 0.05,
133
+ inputs: { value: 20.0, threshold: 25.0 },
134
+ on_result: ->(result) { statuses << result.status }
135
+ )
136
+
137
+ stream.start
138
+ sleep(0.15) # ~3 ticks at :normal
139
+ stream.update_inputs(value: 30.0)
140
+ sleep(0.15) # ~3 ticks at :alert
141
+ stream.stop
142
+
143
+ puts "statuses_sample=#{statuses.uniq.sort.inspect}"
144
+
145
+ # ── Teardown ──────────────────────────────────────────────────────────────────
146
+
147
+ log_entries = logger.call(:dump)
148
+ puts "\nlog_entries=#{log_entries.size}"
149
+ sup.stop
150
+ puts "done=true"