igniter 0.4.0 → 0.4.3
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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/resolver.rb +15 -0
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +57 -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.
|
data/examples/agents.rb
ADDED
|
@@ -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"
|