igniter 0.4.3 → 0.5.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 +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +128 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter/dataflow/diff"
|
|
4
|
+
require "igniter/dataflow/diff_state"
|
|
5
|
+
require "igniter/dataflow/window_filter"
|
|
6
|
+
require "igniter/dataflow/incremental_collection_result"
|
|
7
|
+
require "igniter/dataflow/aggregate_operators"
|
|
8
|
+
require "igniter/dataflow/aggregate_state"
|
|
9
|
+
require "igniter/model/aggregate_node"
|
|
10
|
+
|
|
11
|
+
module Igniter
|
|
12
|
+
# Incremental Dataflow — Phase 1: differential collection processing.
|
|
13
|
+
#
|
|
14
|
+
# Adds `mode: :incremental` to the `collection` DSL node. In this mode the
|
|
15
|
+
# resolver tracks per-item state between `update_inputs` calls and re-runs
|
|
16
|
+
# child contracts only for items that were added or changed. Removed items
|
|
17
|
+
# are retracted automatically. Unchanged items reuse their cached results
|
|
18
|
+
# with zero re-computation cost.
|
|
19
|
+
#
|
|
20
|
+
# Optional `window:` filter limits the active item set before diff computation:
|
|
21
|
+
#
|
|
22
|
+
# window: { last: 100 }
|
|
23
|
+
# Keep only the last 100 items (most recent last in the array).
|
|
24
|
+
#
|
|
25
|
+
# window: { seconds: 300, field: :received_at }
|
|
26
|
+
# Keep items where item[:received_at] >= Time.now - 300.
|
|
27
|
+
#
|
|
28
|
+
# == Usage
|
|
29
|
+
#
|
|
30
|
+
# require "igniter/extensions/dataflow"
|
|
31
|
+
#
|
|
32
|
+
# class SensorPipeline < Igniter::Contract
|
|
33
|
+
# define do
|
|
34
|
+
# input :readings, type: :array
|
|
35
|
+
#
|
|
36
|
+
# collection :processed,
|
|
37
|
+
# with: :readings,
|
|
38
|
+
# each: SensorContract,
|
|
39
|
+
# key: :sensor_id,
|
|
40
|
+
# mode: :incremental,
|
|
41
|
+
# window: { last: 500 }
|
|
42
|
+
#
|
|
43
|
+
# output :processed
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# contract = SensorPipeline.new(readings: initial_readings)
|
|
48
|
+
# contract.resolve_all
|
|
49
|
+
#
|
|
50
|
+
# contract.update_inputs(readings: updated_readings)
|
|
51
|
+
# contract.resolve_all
|
|
52
|
+
#
|
|
53
|
+
# result = contract.result.processed
|
|
54
|
+
# result.diff.added # => [:sensor_42] — child contract was re-run
|
|
55
|
+
# result.diff.changed # => [:sensor_7] — child contract was re-run
|
|
56
|
+
# result.diff.removed # => [:sensor_3] — retracted from result
|
|
57
|
+
# result.diff.unchanged # => [:sensor_1, :sensor_2] — result reused
|
|
58
|
+
#
|
|
59
|
+
# # Convenience: push events as a diff instead of replacing the full array
|
|
60
|
+
# contract.feed_diff(:readings, add: [new_reading], remove: [:sensor_3])
|
|
61
|
+
#
|
|
62
|
+
module Dataflow
|
|
63
|
+
class DataflowError < Igniter::Error; end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -96,10 +96,6 @@ module Igniter
|
|
|
96
96
|
compute(name, with: from, call: callable, **{ category: :project }.merge(metadata))
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
def aggregate(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
|
|
100
|
-
compute(name, depends_on: depends_on, with: with, call: call, executor: executor, **{ category: :aggregate }.merge(metadata), &block)
|
|
101
|
-
end
|
|
102
|
-
|
|
103
99
|
def guard(name, depends_on: nil, with: nil, call: nil, executor: nil, message: nil,
|
|
104
100
|
eq: UNDEFINED_GUARD_MATCHER, in: UNDEFINED_GUARD_MATCHER, matches: UNDEFINED_GUARD_MATCHER,
|
|
105
101
|
**metadata, &block)
|
|
@@ -210,7 +206,7 @@ module Igniter
|
|
|
210
206
|
)
|
|
211
207
|
end
|
|
212
208
|
|
|
213
|
-
def collection(name, with:, each:, key:, mode: :collect, depends_on: nil, map_inputs: nil, using: nil, **metadata)
|
|
209
|
+
def collection(name, with:, each:, key:, mode: :collect, window: nil, depends_on: nil, map_inputs: nil, using: nil, **metadata)
|
|
214
210
|
raise CompileError, "collection :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
|
|
215
211
|
|
|
216
212
|
add_node(
|
|
@@ -221,6 +217,7 @@ module Igniter
|
|
|
221
217
|
contract_class: each,
|
|
222
218
|
key_name: key,
|
|
223
219
|
mode: mode,
|
|
220
|
+
window: window,
|
|
224
221
|
context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
|
|
225
222
|
input_mapper: map_inputs || using,
|
|
226
223
|
path: scoped_path(name),
|
|
@@ -229,10 +226,55 @@ module Igniter
|
|
|
229
226
|
)
|
|
230
227
|
end
|
|
231
228
|
|
|
232
|
-
|
|
229
|
+
# Declares a maintained aggregate over an incremental collection node.
|
|
230
|
+
#
|
|
231
|
+
# The aggregate updates in O(change) time: only added/changed/removed items
|
|
232
|
+
# affect the result; unchanged items contribute zero work.
|
|
233
|
+
#
|
|
234
|
+
# @param name [Symbol] output name
|
|
235
|
+
# @param from [Symbol] name of an upstream incremental collection node
|
|
236
|
+
# @param count [Proc, nil] ->(item) { bool } — count items matching predicate (nil = count all)
|
|
237
|
+
# @param sum [Proc, nil] ->(item) { numeric }
|
|
238
|
+
# @param avg [Proc, nil] ->(item) { numeric }
|
|
239
|
+
# @param min [Proc, nil] ->(item) { numeric } (O(n) on removal)
|
|
240
|
+
# @param max [Proc, nil] ->(item) { numeric } (O(n) on removal)
|
|
241
|
+
# @param group_count [Proc, nil] ->(item) { group_key } — Hash{key => count}
|
|
242
|
+
# @param initial [Object, nil] initial accumulator for custom aggregates
|
|
243
|
+
# @param add [Proc, nil] ->(acc, item) { new_acc } — custom add
|
|
244
|
+
# @param remove [Proc, nil] ->(acc, item) { new_acc } — custom remove
|
|
245
|
+
#
|
|
246
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
247
|
+
def aggregate(name, from:, count: nil, sum: nil, avg: nil, min: nil, max: nil,
|
|
248
|
+
group_count: nil, initial: nil, add: nil, remove: nil, **metadata)
|
|
249
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
250
|
+
operator = build_aggregate_operator(
|
|
251
|
+
count: count, sum: sum, avg: avg, min: min, max: max,
|
|
252
|
+
group_count: group_count, initial: initial, add: add, remove: remove
|
|
253
|
+
)
|
|
254
|
+
add_node(
|
|
255
|
+
Model::AggregateNode.new(
|
|
256
|
+
id: next_id,
|
|
257
|
+
name: name,
|
|
258
|
+
source_collection: from,
|
|
259
|
+
operator: operator,
|
|
260
|
+
path: scoped_path(name),
|
|
261
|
+
metadata: with_source_location(metadata)
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def remote(name, contract:, inputs:, node: nil, timeout: 30, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
267
|
+
capability: nil, pinned_to: nil, **metadata)
|
|
233
268
|
raise CompileError, "remote :#{name} requires inputs: Hash" unless inputs.is_a?(Hash)
|
|
234
269
|
raise CompileError, "remote :#{name} requires a contract: name" if contract.nil? || contract.to_s.strip.empty?
|
|
235
|
-
|
|
270
|
+
|
|
271
|
+
if capability && pinned_to
|
|
272
|
+
raise CompileError, "remote :#{name}: capability: and pinned_to: are mutually exclusive"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if capability.nil? && pinned_to.nil? && (node.nil? || node.to_s.strip.empty?)
|
|
276
|
+
raise CompileError, "remote :#{name} requires a node: URL"
|
|
277
|
+
end
|
|
236
278
|
|
|
237
279
|
add_node(
|
|
238
280
|
Model::RemoteNode.new(
|
|
@@ -242,6 +284,8 @@ module Igniter
|
|
|
242
284
|
node_url: node.to_s,
|
|
243
285
|
input_mapping: inputs,
|
|
244
286
|
timeout: timeout,
|
|
287
|
+
capability: capability,
|
|
288
|
+
pinned_to: pinned_to,
|
|
245
289
|
path: scoped_path(name),
|
|
246
290
|
metadata: with_source_location(metadata)
|
|
247
291
|
)
|
|
@@ -399,6 +443,26 @@ module Igniter
|
|
|
399
443
|
"#{@scope_stack.join('.')}.output.#{name}"
|
|
400
444
|
end
|
|
401
445
|
|
|
446
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
447
|
+
def build_aggregate_operator(count:, sum:, avg:, min:, max:, group_count:, initial:, add:, remove:)
|
|
448
|
+
if add && remove
|
|
449
|
+
Dataflow::AggregateOperators.custom(initial: initial || 0, add: add, remove: remove)
|
|
450
|
+
elsif sum
|
|
451
|
+
Dataflow::AggregateOperators.sum(projection: sum)
|
|
452
|
+
elsif avg
|
|
453
|
+
Dataflow::AggregateOperators.avg(projection: avg)
|
|
454
|
+
elsif min
|
|
455
|
+
Dataflow::AggregateOperators.min(projection: min)
|
|
456
|
+
elsif max
|
|
457
|
+
Dataflow::AggregateOperators.max(projection: max)
|
|
458
|
+
elsif group_count
|
|
459
|
+
Dataflow::AggregateOperators.group_count(projection: group_count)
|
|
460
|
+
else
|
|
461
|
+
Dataflow::AggregateOperators.count(filter: count)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
465
|
+
|
|
402
466
|
class BranchBuilder
|
|
403
467
|
def self.build(&block)
|
|
404
468
|
new.tap { |builder| builder.instance_eval(&block) }.to_h
|
data/lib/igniter/executor.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Igniter
|
|
|
7
7
|
super
|
|
8
8
|
subclass.instance_variable_set(:@executor_inputs, executor_inputs.transform_values(&:dup))
|
|
9
9
|
subclass.instance_variable_set(:@executor_metadata, executor_metadata.dup)
|
|
10
|
+
# capabilities and fingerprint are NOT inherited — each subclass declares its own
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def input(name, required: true, type: nil, **metadata)
|
|
@@ -51,6 +52,65 @@ module Igniter
|
|
|
51
52
|
new.call(**dependencies)
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
# ─── Capabilities DSL ────────────────────────────────────────────────────
|
|
56
|
+
#
|
|
57
|
+
# Declare what this executor is allowed to do. Capabilities are purely
|
|
58
|
+
# declarative — enforcement requires Igniter::Capabilities::Policy.
|
|
59
|
+
#
|
|
60
|
+
# Known capabilities:
|
|
61
|
+
# :pure — deterministic, no side effects; enables content-addressed caching
|
|
62
|
+
# :network — makes outbound HTTP/TCP connections
|
|
63
|
+
# :database — reads or writes a database
|
|
64
|
+
# :filesystem — reads or writes files
|
|
65
|
+
# :external_api — calls a third-party API
|
|
66
|
+
# :messaging — publishes to a message queue or broker
|
|
67
|
+
# :cache — reads or writes an external cache
|
|
68
|
+
#
|
|
69
|
+
# Example:
|
|
70
|
+
# class PaymentExecutor < Igniter::Executor
|
|
71
|
+
# capabilities :network, :external_api
|
|
72
|
+
# end
|
|
73
|
+
def capabilities(*caps)
|
|
74
|
+
if caps.empty?
|
|
75
|
+
@declared_capabilities ||= []
|
|
76
|
+
else
|
|
77
|
+
existing = @declared_capabilities || []
|
|
78
|
+
@declared_capabilities = (existing + caps.flatten.map(&:to_sym)).uniq.freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def declared_capabilities
|
|
83
|
+
@declared_capabilities || []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Shorthand for `capabilities :pure`.
|
|
87
|
+
# A pure executor is fully deterministic: same inputs → same output, always.
|
|
88
|
+
# Pure executors participate in content-addressed cross-execution caching.
|
|
89
|
+
def pure
|
|
90
|
+
capabilities(:pure)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def pure?
|
|
94
|
+
declared_capabilities.include?(:pure)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ─── Content-addressing fingerprint ──────────────────────────────────────
|
|
98
|
+
#
|
|
99
|
+
# Optional explicit version string used as part of the content-addressing key.
|
|
100
|
+
# Set a new value to invalidate the content cache after changing executor logic
|
|
101
|
+
# while keeping the class name stable.
|
|
102
|
+
#
|
|
103
|
+
# fingerprint "tax_calculator_v2"
|
|
104
|
+
def fingerprint(value = nil)
|
|
105
|
+
return @content_fingerprint || name || "anonymous_executor" if value.nil?
|
|
106
|
+
|
|
107
|
+
@content_fingerprint = value.to_s.freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def content_fingerprint
|
|
111
|
+
@content_fingerprint || name || "anonymous_executor"
|
|
112
|
+
end
|
|
113
|
+
|
|
54
114
|
private
|
|
55
115
|
|
|
56
116
|
def metadata_value(key, value)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter/capabilities"
|
|
4
|
+
|
|
5
|
+
# Patches CompiledGraph with capability introspection methods.
|
|
6
|
+
# The Resolver integration is handled via guard-claused hooks in resolver.rb.
|
|
7
|
+
module Igniter
|
|
8
|
+
module Compiler
|
|
9
|
+
class CompiledGraph
|
|
10
|
+
# Returns a Hash of { node_name => [capabilities] } for every compute/effect
|
|
11
|
+
# node whose executor declares at least one capability.
|
|
12
|
+
def required_capabilities
|
|
13
|
+
(nodes + outputs).each_with_object({}) do |node, memo|
|
|
14
|
+
caps = node_capabilities(node)
|
|
15
|
+
memo[node.name] = caps unless caps.empty?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns declared capabilities for a single node by name.
|
|
20
|
+
def capabilities_for(node_name)
|
|
21
|
+
sym = node_name.to_sym
|
|
22
|
+
target = (nodes + outputs).find { |n| n.name == sym }
|
|
23
|
+
return [] unless target
|
|
24
|
+
|
|
25
|
+
node_capabilities(target)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def node_capabilities(node)
|
|
31
|
+
callable = node.respond_to?(:callable) ? node.callable : nil
|
|
32
|
+
callable ||= node.respond_to?(:adapter_class) ? node.adapter_class : nil
|
|
33
|
+
return [] unless callable.is_a?(Class) && callable <= Igniter::Executor
|
|
34
|
+
|
|
35
|
+
callable.declared_capabilities
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/dataflow"
|
|
5
|
+
|
|
6
|
+
# Activates incremental dataflow support for all contracts.
|
|
7
|
+
#
|
|
8
|
+
# After requiring this file:
|
|
9
|
+
#
|
|
10
|
+
# - `collection` DSL accepts `mode: :incremental` and `window:` options.
|
|
11
|
+
# - Contracts gain `#feed_diff` — push event-style diffs instead of full arrays.
|
|
12
|
+
# - Contracts gain `#collection_diff` — inspect what changed after the last resolve.
|
|
13
|
+
#
|
|
14
|
+
# == Usage
|
|
15
|
+
#
|
|
16
|
+
# require "igniter/extensions/dataflow"
|
|
17
|
+
#
|
|
18
|
+
# class SensorPipeline < Igniter::Contract
|
|
19
|
+
# define do
|
|
20
|
+
# input :readings, type: :array
|
|
21
|
+
#
|
|
22
|
+
# collection :processed,
|
|
23
|
+
# with: :readings,
|
|
24
|
+
# each: SensorContract,
|
|
25
|
+
# key: :sensor_id,
|
|
26
|
+
# mode: :incremental,
|
|
27
|
+
# window: { last: 500 }
|
|
28
|
+
#
|
|
29
|
+
# output :processed
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# contract = SensorPipeline.new(readings: initial_readings)
|
|
34
|
+
# contract.resolve_all
|
|
35
|
+
#
|
|
36
|
+
# # Push new events without replacing the full array
|
|
37
|
+
# contract.feed_diff(:readings, add: [new_reading], remove: [:sensor_old])
|
|
38
|
+
# contract.resolve_all
|
|
39
|
+
#
|
|
40
|
+
# # Inspect what changed
|
|
41
|
+
# diff = contract.collection_diff(:processed)
|
|
42
|
+
# diff.added # => [:sensor_42]
|
|
43
|
+
# diff.removed # => [:sensor_old]
|
|
44
|
+
# diff.changed # => []
|
|
45
|
+
# diff.unchanged # => [:sensor_1, :sensor_2, ...]
|
|
46
|
+
#
|
|
47
|
+
module Igniter
|
|
48
|
+
module Extensions
|
|
49
|
+
module Dataflow
|
|
50
|
+
module InstanceMethods
|
|
51
|
+
# Push a diff to a collection input without replacing the full array.
|
|
52
|
+
#
|
|
53
|
+
# Automatically finds the incremental collection node that uses +input_name+
|
|
54
|
+
# as its source dependency, uses its key_name to merge the diff into the
|
|
55
|
+
# current input array, then calls update_inputs.
|
|
56
|
+
#
|
|
57
|
+
# @param input_name [Symbol, String] the contract input holding the collection
|
|
58
|
+
# @param add [Array<Hash>] new items to append
|
|
59
|
+
# @param remove [Array] keys or Hash items to remove (matched by key_name)
|
|
60
|
+
# @param update [Array<Hash>] updated versions of existing items (replace by key)
|
|
61
|
+
# @return [self]
|
|
62
|
+
#
|
|
63
|
+
# @raise [ArgumentError] if no incremental collection node uses the given input
|
|
64
|
+
def feed_diff(input_name, add: [], remove: [], update: [])
|
|
65
|
+
sym = input_name.to_sym
|
|
66
|
+
key_name = _incremental_node_for(sym).key_name
|
|
67
|
+
current = execution.inputs[sym].dup || []
|
|
68
|
+
items = _apply_diff(current, key_name, add: add, remove: remove, update: update)
|
|
69
|
+
execution.update_inputs(sym => items)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns the Diff from the last resolve for a named collection node.
|
|
74
|
+
#
|
|
75
|
+
# @param collection_name [Symbol, String] the collection node name
|
|
76
|
+
# @return [Igniter::Dataflow::Diff, nil] nil if not yet resolved or not incremental
|
|
77
|
+
def collection_diff(collection_name)
|
|
78
|
+
state = execution.cache.fetch(collection_name.to_sym)
|
|
79
|
+
value = state&.value
|
|
80
|
+
value.respond_to?(:diff) ? value.diff : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def _apply_diff(items, key_name, add:, remove:, update:)
|
|
86
|
+
remove_keys = Array(remove).map { |e| e.is_a?(Hash) ? e.fetch(key_name) : e }.to_set
|
|
87
|
+
result = items.reject { |item| remove_keys.include?(item.fetch(key_name)) }
|
|
88
|
+
_apply_updates(result, key_name, update)
|
|
89
|
+
result.concat(Array(add))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def _apply_updates(items, key_name, updates)
|
|
93
|
+
Array(updates).each do |updated|
|
|
94
|
+
k = updated.fetch(key_name)
|
|
95
|
+
idx = items.index { |item| item.fetch(key_name) == k }
|
|
96
|
+
idx ? items[idx] = updated : items << updated
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def _incremental_node_for(input_name)
|
|
101
|
+
node = execution.compiled_graph.nodes.find do |n|
|
|
102
|
+
n.kind == :collection &&
|
|
103
|
+
n.mode == :incremental &&
|
|
104
|
+
n.source_dependency == input_name
|
|
105
|
+
end
|
|
106
|
+
return node if node
|
|
107
|
+
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"No incremental collection node found for input '#{input_name}'. " \
|
|
110
|
+
"Ensure the collection is declared with mode: :incremental."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Igniter::Contract.include(Igniter::Extensions::Dataflow::InstanceMethods)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/incremental"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Extensions
|
|
8
|
+
# Patches Igniter::Contract with:
|
|
9
|
+
# - Instance method: resolve_incrementally(new_inputs = {}) → Incremental::Result
|
|
10
|
+
#
|
|
11
|
+
# The method updates inputs, re-resolves, and returns a structured report
|
|
12
|
+
# of which nodes changed, were memoized, or were backdated.
|
|
13
|
+
#
|
|
14
|
+
# Applied globally via:
|
|
15
|
+
# Igniter::Contract.include(Igniter::Extensions::Incremental)
|
|
16
|
+
#
|
|
17
|
+
module Incremental
|
|
18
|
+
def self.included(base)
|
|
19
|
+
base.include(InstanceMethods)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module InstanceMethods
|
|
23
|
+
# Update inputs and re-resolve, returning a detailed incremental result.
|
|
24
|
+
#
|
|
25
|
+
# If called without arguments on an already-resolved contract, it
|
|
26
|
+
# re-resolves with current inputs (useful for observing memoization).
|
|
27
|
+
#
|
|
28
|
+
# @param new_inputs [Hash] input values to update before re-resolving
|
|
29
|
+
# @return [Igniter::Incremental::Result]
|
|
30
|
+
def resolve_incrementally(new_inputs = {})
|
|
31
|
+
unless execution.cache.values.any?
|
|
32
|
+
raise Igniter::Incremental::IncrementalError,
|
|
33
|
+
"Contract has not been executed yet — call resolve_all first, " \
|
|
34
|
+
"then resolve_incrementally to get incremental results"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
tracker = Igniter::Incremental::Tracker.new(execution)
|
|
38
|
+
tracker.start!
|
|
39
|
+
|
|
40
|
+
update_inputs(new_inputs) if new_inputs.any?
|
|
41
|
+
resolve_all
|
|
42
|
+
|
|
43
|
+
tracker.build_result
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Igniter::Contract.include(Igniter::Extensions::Incremental)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Igniter Mesh — Phase 1 (Static) + Phase 2 (Dynamic Discovery)
|
|
4
|
+
#
|
|
5
|
+
# Extends the remote: DSL with capability-based and pinned routing modes, and
|
|
6
|
+
# enables dynamic peer discovery so that the peer topology can be maintained
|
|
7
|
+
# without static add_peer declarations.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# require "igniter/extensions/mesh"
|
|
11
|
+
#
|
|
12
|
+
# # Phase 1 — static topology (still works):
|
|
13
|
+
# Igniter::Mesh.configure do |c|
|
|
14
|
+
# c.add_peer "orders-node",
|
|
15
|
+
# url: "http://orders.internal:4567",
|
|
16
|
+
# capabilities: [:orders, :inventory]
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# # Phase 2 — dynamic discovery:
|
|
20
|
+
# Igniter::Mesh.configure do |c|
|
|
21
|
+
# c.peer_name = "api-node"
|
|
22
|
+
# c.local_url = "http://api.internal:4567"
|
|
23
|
+
# c.local_capabilities = [:api]
|
|
24
|
+
# c.seeds = %w[http://orders.internal:4567 http://audit.internal:4567]
|
|
25
|
+
# c.discovery_interval = 30
|
|
26
|
+
# end
|
|
27
|
+
# Igniter::Mesh.start_discovery!
|
|
28
|
+
#
|
|
29
|
+
require "igniter"
|
|
30
|
+
require "igniter/server"
|
|
31
|
+
require "igniter/mesh"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
# Mixin that enables stable cross-execution cache keys for objects used as
|
|
5
|
+
# compute node dependencies (e.g. ActiveRecord models).
|
|
6
|
+
#
|
|
7
|
+
# Include this module in any class whose instances are passed as node deps:
|
|
8
|
+
#
|
|
9
|
+
# class Trade < ApplicationRecord
|
|
10
|
+
# include Igniter::Fingerprint
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The Rails Railtie includes this automatically in ApplicationRecord when
|
|
14
|
+
# the igniter-rails integration is loaded.
|
|
15
|
+
#
|
|
16
|
+
# == Custom fingerprints
|
|
17
|
+
#
|
|
18
|
+
# Override #igniter_fingerprint for non-AR objects or custom invalidation logic:
|
|
19
|
+
#
|
|
20
|
+
# class PricingConfig
|
|
21
|
+
# include Igniter::Fingerprint
|
|
22
|
+
#
|
|
23
|
+
# def igniter_fingerprint
|
|
24
|
+
# "PricingConfig:#{version}:#{market}"
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
module Fingerprint
|
|
28
|
+
# Returns a stable string that uniquely identifies this object's state.
|
|
29
|
+
# Changing the returned value invalidates any NodeCache entry that depends on it.
|
|
30
|
+
#
|
|
31
|
+
# Default: "{ClassName}:{id}:{updated_at_unix}" — works for any AR record.
|
|
32
|
+
# Returns "{ClassName}:{id}" for objects without updated_at.
|
|
33
|
+
def igniter_fingerprint
|
|
34
|
+
if respond_to?(:updated_at) && updated_at
|
|
35
|
+
"#{self.class.name}:#{id}:#{updated_at.to_i}"
|
|
36
|
+
elsif respond_to?(:id)
|
|
37
|
+
"#{self.class.name}:#{id}"
|
|
38
|
+
else
|
|
39
|
+
"#{self.class.name}:#{object_id}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Incremental
|
|
5
|
+
# Renders an Incremental::Result as a human-readable text block.
|
|
6
|
+
#
|
|
7
|
+
# Example output:
|
|
8
|
+
#
|
|
9
|
+
# Incremental Execution Report
|
|
10
|
+
# ─────────────────────────────────────────
|
|
11
|
+
# Recomputed: 1 node(s)
|
|
12
|
+
# Skipped: 2 node(s) (deps unchanged)
|
|
13
|
+
# Backdated: 0 node(s) (value unchanged)
|
|
14
|
+
#
|
|
15
|
+
# CHANGED OUTPUTS (1):
|
|
16
|
+
# :converted_price 1.05 → 1.12
|
|
17
|
+
#
|
|
18
|
+
# SKIPPED NODES (memoized):
|
|
19
|
+
# :tier_discount :adjusted_price
|
|
20
|
+
#
|
|
21
|
+
module Formatter
|
|
22
|
+
VALUE_MAX = 60
|
|
23
|
+
LINE = "─" * 42
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def format(result) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
27
|
+
lines = []
|
|
28
|
+
lines << "Incremental Execution Report"
|
|
29
|
+
lines << LINE
|
|
30
|
+
lines << "Recomputed: #{result.recomputed_count} node(s)"
|
|
31
|
+
lines << "Skipped: #{result.skipped_nodes.size} node(s) (deps unchanged)"
|
|
32
|
+
lines << "Backdated: #{result.backdated_nodes.size} node(s) (value unchanged)"
|
|
33
|
+
lines << ""
|
|
34
|
+
|
|
35
|
+
if result.changed_outputs.any?
|
|
36
|
+
lines << "CHANGED OUTPUTS (#{result.changed_outputs.size}):"
|
|
37
|
+
result.changed_outputs.each do |name, diff|
|
|
38
|
+
lines << " :#{name} #{fmt(diff[:from])} → #{fmt(diff[:to])}"
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
lines << "No output values changed."
|
|
42
|
+
end
|
|
43
|
+
lines << ""
|
|
44
|
+
|
|
45
|
+
if result.skipped_nodes.any?
|
|
46
|
+
lines << "SKIPPED (memoized, #{result.skipped_nodes.size}):"
|
|
47
|
+
lines << " #{result.skipped_nodes.map { |n| ":#{n}" }.join(" ")}"
|
|
48
|
+
lines << ""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if result.backdated_nodes.any?
|
|
52
|
+
lines << "BACKDATED (recomputed → same value, #{result.backdated_nodes.size}):"
|
|
53
|
+
lines << " #{result.backdated_nodes.map { |n| ":#{n}" }.join(" ")}"
|
|
54
|
+
lines << ""
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if result.changed_nodes.any?
|
|
58
|
+
lines << "CHANGED (#{result.changed_nodes.size}):"
|
|
59
|
+
lines << " #{result.changed_nodes.map { |n| ":#{n}" }.join(" ")}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
lines.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def fmt(value) # rubocop:disable Metrics/CyclomaticComplexity
|
|
68
|
+
str = case value
|
|
69
|
+
when nil then "nil"
|
|
70
|
+
when String then value.inspect
|
|
71
|
+
when Symbol then value.inspect
|
|
72
|
+
when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
|
|
73
|
+
when Array then "[#{value.map(&:inspect).join(", ")}]"
|
|
74
|
+
else value.inspect
|
|
75
|
+
end
|
|
76
|
+
str.length > VALUE_MAX ? "#{str[0, VALUE_MAX - 3]}..." : str
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|