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.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../agent"
4
+
5
+ module Igniter
6
+ module Replication
7
+ # Agent that handles :replicate messages to deploy Igniter to remote servers.
8
+ #
9
+ # In production, start via ReplicationAgent.start and send messages through
10
+ # the Ref. In tests, instantiate directly and call handle_message/1.
11
+ #
12
+ # Message payload keys:
13
+ # host: [String] (required) remote hostname or IP
14
+ # user: [String] (required) SSH username
15
+ # key: [String] path to SSH private key (optional)
16
+ # port: [Integer] SSH port (default: 22)
17
+ # env: [Hash] environment variables for remote (default: {})
18
+ # strategy: [Symbol] :git, :gem, or :tarball (default: :git)
19
+ # target_path: [String] installation path on remote (default: /opt/igniter)
20
+ # bootstrapper_options: [Hash] forwarded to the bootstrapper constructor
21
+ #
22
+ class ReplicationAgent < Igniter::Agent
23
+ MAX_REPLICAS = 10
24
+
25
+ initial_state events: []
26
+
27
+ # Class-level handler for the agent mailbox runtime.
28
+ # Instantiates a temporary agent to run the replication so that
29
+ # deliver/1 can be overridden in subclasses.
30
+ on :replicate do |state:, payload:, **|
31
+ agent = new
32
+ agent.send(:run_replicate, payload)
33
+ state
34
+ end
35
+
36
+ # Emit a named lifecycle event. Override or stub in tests.
37
+ #
38
+ # @param type [Symbol] event name (e.g. :replication_started)
39
+ # @param payload [Hash] associated data
40
+ def deliver(type, payload = {})
41
+ # Base implementation: no-op. Override in subclasses for real routing.
42
+ end
43
+
44
+ # Process a raw message hash synchronously (used in tests and internal tooling).
45
+ #
46
+ # @param message [Hash] must have :type key; optional :payload key
47
+ def handle_message(message)
48
+ type = message.fetch(:type).to_sym
49
+ payload = message.fetch(:payload, {})
50
+ return unless type == :replicate
51
+
52
+ run_replicate(payload)
53
+ end
54
+
55
+ private
56
+
57
+ def run_replicate(payload) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
58
+ host = payload[:host] || raise(ArgumentError, "host is required")
59
+ user = payload[:user] || raise(ArgumentError, "user is required")
60
+ key = payload[:key]
61
+ port = payload.fetch(:port, 22)
62
+ env = payload.fetch(:env, {})
63
+ strategy = payload.fetch(:strategy, :git).to_sym
64
+ target_path = payload.fetch(:target_path, "/opt/igniter")
65
+ bs_options = payload.fetch(:bootstrapper_options, {})
66
+
67
+ session = SSHSession.new(host: host, user: user, key: key, port: port)
68
+ bootstrapper = Replication.bootstrapper_for(strategy, **bs_options)
69
+ manifest = Manifest.current
70
+
71
+ deliver(:replication_started, host: host, instance_id: manifest.instance_id)
72
+
73
+ bootstrapper.install(session: session, manifest: manifest,
74
+ env: env, target_path: target_path)
75
+ bootstrapper.start(session: session, manifest: manifest, target_path: target_path)
76
+ verified = bootstrapper.verify(session: session, target_path: target_path)
77
+
78
+ deliver(:replication_completed,
79
+ host: host, instance_id: manifest.instance_id, verified: verified)
80
+ rescue SSHSession::SSHError => e
81
+ deliver(:replication_failed, host: host, error: e.message)
82
+ rescue ArgumentError => e
83
+ deliver(:replication_failed, host: payload[:host], error: e.message)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Replication
5
+ # Module-level registry of named NodeRoles.
6
+ #
7
+ # @example
8
+ # RoleRegistry.define(:worker,
9
+ # contracts: ["ComputeContract"],
10
+ # capabilities: [:compute],
11
+ # env_overrides: { "WORKER_POOL" => "4" }
12
+ # )
13
+ #
14
+ # role = RoleRegistry.fetch(:worker)
15
+ # role.env_overrides # => { "WORKER_POOL" => "4" }
16
+ module RoleRegistry
17
+ @roles = {}
18
+
19
+ class << self
20
+ # Define and register a new role.
21
+ #
22
+ # @param name [Symbol, String]
23
+ # @param contracts [Array<String>]
24
+ # @param capabilities [Array<Symbol>]
25
+ # @param env_overrides [Hash]
26
+ # @param tags [Array<Symbol>]
27
+ # @return [NodeRole]
28
+ def define(name, contracts: [], capabilities: [], env_overrides: {}, tags: [])
29
+ role = NodeRole.new(
30
+ name: name,
31
+ contracts: contracts,
32
+ capabilities: capabilities,
33
+ env_overrides: env_overrides,
34
+ tags: tags
35
+ )
36
+ @roles[role.name] = role
37
+ end
38
+
39
+ # Fetch a role by name.
40
+ #
41
+ # @param name [Symbol, String]
42
+ # @return [NodeRole]
43
+ # @raise [ArgumentError] if not registered
44
+ def fetch(name)
45
+ @roles.fetch(name.to_sym) do
46
+ raise ArgumentError,
47
+ "Unknown role: #{name}. Available: #{@roles.keys.join(", ")}"
48
+ end
49
+ end
50
+
51
+ # Returns true if a role with the given name is registered.
52
+ #
53
+ # @param name [Symbol, String]
54
+ # @return [Boolean]
55
+ def registered?(name)
56
+ @roles.key?(name.to_sym)
57
+ end
58
+
59
+ # All registered roles (copy to prevent external mutation).
60
+ #
61
+ # @return [Hash{Symbol => NodeRole}]
62
+ def all
63
+ @roles.dup
64
+ end
65
+
66
+ # Remove all registrations. Useful in tests.
67
+ def reset!
68
+ @roles = {}
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Igniter
6
+ module Replication
7
+ # Thin subprocess wrapper over the +ssh+ and +scp+ CLI tools.
8
+ #
9
+ # Provides exec/exec! for running remote commands and upload! for
10
+ # copying local files to the remote host. No external gems required.
11
+ class SSHSession
12
+ class SSHError < Igniter::Error; end
13
+
14
+ DEFAULT_CONNECT_TIMEOUT = 10
15
+
16
+ def initialize(host:, user:, key: nil, port: 22, connect_timeout: DEFAULT_CONNECT_TIMEOUT)
17
+ @host = host
18
+ @user = user
19
+ @key = key
20
+ @port = port
21
+ @connect_timeout = connect_timeout
22
+ end
23
+
24
+ # Run a command on the remote host. Raises SSHError on non-zero exit.
25
+ # Returns stdout string on success.
26
+ def exec!(command)
27
+ result = exec(command)
28
+ raise SSHError, "SSH command failed on #{@host}: #{command.inspect}\n#{result[:stderr]}" \
29
+ unless result[:success]
30
+
31
+ result[:stdout]
32
+ end
33
+
34
+ # Run a command on the remote host.
35
+ # Returns a Hash: { stdout:, stderr:, success:, exit_code: }
36
+ def exec(command)
37
+ stdout, stderr, status = Open3.capture3(*build_cmd(command))
38
+ { stdout: stdout, stderr: stderr, success: status.success?, exit_code: status.exitstatus }
39
+ end
40
+
41
+ # Upload a local file to the remote host via scp.
42
+ # Raises SSHError on failure.
43
+ def upload!(local_path, remote_path)
44
+ args = ["scp", *scp_opts, "-P", @port.to_s, local_path, "#{@user}@#{@host}:#{remote_path}"]
45
+ _, stderr, status = Open3.capture3(*args)
46
+ raise SSHError, "SCP upload failed to #{@host}: #{stderr}" unless status.success?
47
+ end
48
+
49
+ # Quick connectivity test. Returns true if the remote responds.
50
+ def test_connection
51
+ exec("echo ok")[:success]
52
+ end
53
+
54
+ private
55
+
56
+ def build_cmd(command)
57
+ ["ssh", *ssh_opts, "-p", @port.to_s, "#{@user}@#{@host}", command]
58
+ end
59
+
60
+ def ssh_opts
61
+ opts = [
62
+ "-o", "StrictHostKeyChecking=no",
63
+ "-o", "BatchMode=yes",
64
+ "-o", "ConnectTimeout=#{@connect_timeout}"
65
+ ]
66
+ opts += ["-i", @key] if @key
67
+ opts
68
+ end
69
+
70
+ def scp_opts
71
+ opts = ["-o", "StrictHostKeyChecking=no", "-B"]
72
+ opts += ["-i", @key] if @key
73
+ opts
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "replication/manifest"
4
+ require_relative "replication/ssh_session"
5
+ require_relative "replication/bootstrapper"
6
+ require_relative "replication/bootstrappers/git"
7
+ require_relative "replication/bootstrappers/gem"
8
+ require_relative "replication/bootstrappers/tarball"
9
+ require_relative "replication/replication_agent"
10
+ require_relative "replication/node_role"
11
+ require_relative "replication/role_registry"
12
+ require_relative "replication/network_topology"
13
+ require_relative "replication/expansion_plan"
14
+ require_relative "replication/expansion_planner"
15
+ require_relative "replication/reflective_replication_agent"
16
+
17
+ module Igniter
18
+ # Self-replication capability: deploy a running Igniter instance to a
19
+ # remote server via SSH using one of three deployment strategies.
20
+ #
21
+ # Usage:
22
+ # require "igniter/replication"
23
+ # ref = Igniter::Replication::ReplicationAgent.start
24
+ # ref.send(:replicate,
25
+ # host: "10.0.0.2",
26
+ # user: "deploy",
27
+ # strategy: :git,
28
+ # bootstrapper_options: { repo_url: "https://github.com/org/app" }
29
+ # )
30
+ #
31
+ module Replication
32
+ ReplicationError = Class.new(Igniter::Error)
33
+
34
+ BOOTSTRAPPERS = {
35
+ git: Bootstrappers::Git,
36
+ gem: Bootstrappers::Gem,
37
+ tarball: Bootstrappers::Tarball
38
+ }.freeze
39
+
40
+ # Instantiate the bootstrapper for the given strategy.
41
+ #
42
+ # @param strategy [Symbol] one of :git, :gem, :tarball
43
+ # @param options [Hash] forwarded to the bootstrapper constructor
44
+ # @return [Bootstrapper]
45
+ # @raise [ArgumentError] for unknown strategies
46
+ def self.bootstrapper_for(strategy, **options)
47
+ klass = BOOTSTRAPPERS.fetch(strategy.to_sym) do
48
+ raise ArgumentError,
49
+ "Unknown bootstrapper: #{strategy}. Available: #{BOOTSTRAPPERS.keys.join(", ")}"
50
+ end
51
+ klass.new(**options)
52
+ end
53
+ end
54
+ end
@@ -13,24 +13,27 @@ module Igniter
13
13
  @mutex.synchronize { @states[node_name.to_sym] }
14
14
  end
15
15
 
16
- def write(state)
16
+ def write(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
17
17
  @mutex.synchronize do
18
18
  current = @states[state.node.name]
19
19
  version = state.version || (current&.running? ? current.version : next_version(current))
20
+ value_version = compute_value_version(state, current)
20
21
  @states[state.node.name] = NodeState.new(
21
22
  node: state.node,
22
23
  status: state.status,
23
24
  value: state.value,
24
25
  error: state.error,
25
26
  version: version,
27
+ value_version: value_version,
26
28
  resolved_at: state.resolved_at,
27
- invalidated_by: state.invalidated_by
29
+ invalidated_by: state.invalidated_by,
30
+ dep_snapshot: state.dep_snapshot
28
31
  )
29
32
  @condition.broadcast
30
33
  end
31
34
  end
32
35
 
33
- def begin_resolution(node)
36
+ def begin_resolution(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34
37
  @mutex.synchronize do
35
38
  loop do
36
39
  current = @states[node.name]
@@ -43,8 +46,10 @@ module Igniter
43
46
  value: current&.value,
44
47
  error: current&.error,
45
48
  version: next_version(current),
49
+ value_version: current&.value_version,
46
50
  resolved_at: current&.resolved_at || Time.now.utc,
47
- invalidated_by: nil
51
+ invalidated_by: nil,
52
+ dep_snapshot: current&.dep_snapshot
48
53
  )
49
54
  return [:started, @states[node.name]]
50
55
  end
@@ -54,7 +59,7 @@ module Igniter
54
59
  end
55
60
  end
56
61
 
57
- def stale!(node, invalidated_by:)
62
+ def stale!(node, invalidated_by:) # rubocop:disable Metrics/MethodLength
58
63
  @mutex.synchronize do
59
64
  current = @states[node.name]
60
65
  return unless current
@@ -65,8 +70,10 @@ module Igniter
65
70
  value: current.value,
66
71
  error: current.error,
67
72
  version: current.version + 1,
73
+ value_version: current.value_version,
68
74
  resolved_at: current.resolved_at,
69
- invalidated_by: invalidated_by
75
+ invalidated_by: invalidated_by,
76
+ dep_snapshot: current.dep_snapshot
70
77
  )
71
78
  @condition.broadcast
72
79
  end
@@ -92,6 +99,28 @@ module Igniter
92
99
  def next_version(current)
93
100
  current ? current.version + 1 : 1
94
101
  end
102
+
103
+ # value_version only increments when the actual value changes.
104
+ # When state.value_version is set explicitly (backdating from resolver), use it.
105
+ # For :succeeded states, compare the new value against the old value:
106
+ # - same value → preserve value_version
107
+ # - different (or first time) → increment
108
+ # For all other statuses (failed, pending, etc.) → no value_version.
109
+ def compute_value_version(state, current)
110
+ return state.value_version if state.value_version
111
+
112
+ return nil unless state.status == :succeeded
113
+
114
+ base_vv = current&.value_version || 0
115
+ # When current is :running, current.value holds the pre-stale value for comparison.
116
+ old_value = current&.value
117
+
118
+ if base_vv.positive? && old_value == state.value
119
+ base_vv
120
+ else
121
+ base_vv + 1
122
+ end
123
+ end
95
124
  end
96
125
  end
97
126
  end
@@ -59,6 +59,24 @@ module Igniter
59
59
  self
60
60
  end
61
61
 
62
+ # Returns the DiffState for an incremental collection node (created on first access).
63
+ # Persists across update_inputs calls for the lifetime of this Execution.
64
+ def diff_state_for(node_name)
65
+ @diff_states ||= {}
66
+ @diff_states[node_name.to_sym] ||= Igniter::Dataflow::DiffState.new
67
+ end
68
+
69
+
70
+ # Returns the AggregateState for an aggregate node (created on first access).
71
+ # Persists across update_inputs calls for the lifetime of this Execution.
72
+ def aggregate_state_for(node_name)
73
+ @aggregate_states ||= {}
74
+ @aggregate_states[node_name.to_sym] ||= begin
75
+ node = compiled_graph.fetch_node(node_name)
76
+ Igniter::Dataflow::AggregateState.new(node.operator)
77
+ end
78
+ end
79
+
62
80
  def resume(node_name, value:)
63
81
  node = compiled_graph.fetch_node(node_name)
64
82
  current = cache.fetch(node.name)
@@ -251,10 +269,12 @@ module Igniter
251
269
  memo[node_name] = {
252
270
  status: state.status,
253
271
  version: state.version,
272
+ value_version: state.value_version,
254
273
  resolved_at: state.resolved_at&.iso8601,
255
274
  invalidated_by: state.invalidated_by,
256
275
  value: serialize_state_value(state.value),
257
- error: serialize_state_error(state.error)
276
+ error: serialize_state_error(state.error),
277
+ dep_snapshot: state.dep_snapshot
258
278
  }
259
279
  end
260
280
  end
@@ -262,14 +282,18 @@ module Igniter
262
282
  def deserialize_states(snapshot_states)
263
283
  snapshot_states.each_with_object({}) do |(node_name, state_data), memo|
264
284
  node = compiled_graph.fetch_node(node_name)
285
+ raw_dep_snapshot = state_data[:dep_snapshot] || state_data["dep_snapshot"]
286
+ dep_snapshot = raw_dep_snapshot&.transform_keys(&:to_sym)
265
287
  memo[node.name] = NodeState.new(
266
288
  node: node,
267
289
  status: (state_data[:status] || state_data["status"]).to_sym,
268
290
  value: deserialize_state_value(node, state_data[:value] || state_data["value"]),
269
291
  error: deserialize_state_error(state_data[:error] || state_data["error"]),
270
292
  version: state_data[:version] || state_data["version"],
293
+ value_version: state_data[:value_version] || state_data["value_version"],
271
294
  resolved_at: deserialize_time(state_data[:resolved_at] || state_data["resolved_at"]),
272
- invalidated_by: (state_data[:invalidated_by] || state_data["invalidated_by"])&.to_sym
295
+ invalidated_by: (state_data[:invalidated_by] || state_data["invalidated_by"])&.to_sym,
296
+ dep_snapshot: dep_snapshot
273
297
  )
274
298
  end
275
299
  end
@@ -64,7 +64,8 @@ module Igniter
64
64
  next if inputs.key?(node.name)
65
65
  next unless node.default?
66
66
 
67
- inputs[node.name] = node.default
67
+ d = node.default
68
+ inputs[node.name] = d.respond_to?(:call) ? d.call : d
68
69
  end
69
70
  end
70
71
 
@@ -77,7 +78,10 @@ module Igniter
77
78
  end
78
79
 
79
80
  def missing_value!(input_node)
80
- return input_node.default if input_node.default?
81
+ if input_node.default?
82
+ d = input_node.default
83
+ return d.respond_to?(:call) ? d.call : d
84
+ end
81
85
  return nil unless input_node.required?
82
86
 
83
87
  raise input_error(input_node, "Missing required input: #{input_node.name}")
@@ -3,16 +3,20 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class NodeState
6
- attr_reader :node, :status, :value, :error, :version, :resolved_at, :invalidated_by
6
+ attr_reader :node, :status, :value, :error, :version, :value_version,
7
+ :resolved_at, :invalidated_by, :dep_snapshot
7
8
 
8
- def initialize(node:, status:, value: nil, error: nil, version: nil, resolved_at: Time.now.utc, invalidated_by: nil)
9
+ def initialize(node:, status:, value: nil, error: nil, version: nil, value_version: nil, # rubocop:disable Metrics/ParameterLists
10
+ resolved_at: Time.now.utc, invalidated_by: nil, dep_snapshot: nil)
9
11
  @node = node
10
12
  @status = status
11
13
  @value = value
12
14
  @error = error
13
15
  @version = version
16
+ @value_version = value_version
14
17
  @resolved_at = resolved_at
15
18
  @invalidated_by = invalidated_by
19
+ @dep_snapshot = dep_snapshot
16
20
  end
17
21
 
18
22
  def stale?
@@ -40,6 +44,7 @@ module Igniter
40
44
  node_name: node.name,
41
45
  status: status,
42
46
  version: version,
47
+ value_version: value_version,
43
48
  resolved_at: resolved_at,
44
49
  invalidated_by: invalidated_by,
45
50
  value: value,