insika 0.3.0 → 0.7.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -22,6 +22,11 @@ module Insika
22
22
  # KEY — memory TTLs sweep on their own knob (`memory_ttl_days`), gated by
23
23
  # neither retention_days nor the age-based claim (D5).
24
24
  MEMORY_TTL_KEY = "memory_ttl_claim"
25
+ # the ARTIFACT TTL's own daily claim — same discipline as the memory TTL:
26
+ # reports expire on their own knob (`artifact_ttl_days`), never gated by
27
+ # retention_days (a deployment that keeps conversations forever must still
28
+ # expire the PII-carrying reports).
29
+ ARTIFACT_TTL_KEY = "artifact_ttl_claim"
25
30
  WINDOW = 86_400 # one sweep per day, at most
26
31
 
27
32
  TERMINAL = %w[completed failed cancelled].freeze
@@ -32,7 +37,8 @@ module Insika
32
37
  settings_store: nil, budget_ledger: nil, funnel_store: nil,
33
38
  followup_store: nil, contact_store: nil, proposal_store: nil,
34
39
  model_visible_trace_store: nil,
35
- store:, window: WINDOW, now: nil, harvest_store: nil)
40
+ store:, window: WINDOW, now: nil, harvest_store: nil,
41
+ artifact_store: nil)
36
42
  @session_store = session_store
37
43
  @task_store = task_store
38
44
  @checkpoint_store = checkpoint_store
@@ -53,6 +59,7 @@ module Insika
53
59
  @window = window
54
60
  @now = now # injectable for specs (a deterministic "today")
55
61
  @harvest_store = harvest_store # ; nil = nothing to sweep
62
+ @artifact_store = artifact_store # ; nil = nothing to sweep
56
63
  end
57
64
 
58
65
  # the sweep reads the memory store's cells/records (specs seed
@@ -68,11 +75,13 @@ module Insika
68
75
  def run
69
76
  budget_cells = sweep_budget_cells
70
77
  memory_ttl = sweep_memory_ttl
78
+ artifacts = sweep_artifacts
71
79
  days = retention_days
72
80
  unless days.to_i.positive? && claim_window
73
81
  summary = { claimed: false }
74
82
  summary[:budget_cells] = budget_cells if budget_cells
75
83
  summary[:memory_ttl] = memory_ttl if memory_ttl
84
+ summary[:artifacts] = artifacts if artifacts
76
85
  return summary
77
86
  end
78
87
 
@@ -101,6 +110,7 @@ module Insika
101
110
  summary[:harvest] = @harvest_store.delete_older_than(cutoff) if @harvest_store
102
111
  summary[:budget_cells] = budget_cells if budget_cells
103
112
  summary[:memory_ttl] = memory_ttl if memory_ttl
113
+ summary[:artifacts] = artifacts if artifacts
104
114
  summary
105
115
  end
106
116
 
@@ -166,6 +176,31 @@ module Insika
166
176
  end
167
177
  end
168
178
 
179
+ # the artifact TTL sweep — the guarantee that PII inside a report expires
180
+ # even though no reader can see inside the opaque HTML. Its OWN daily
181
+ # claim + knob (`artifact_ttl_days` from settings; Integer; nil/absent =
182
+ # OFF), never gated by retention_days — a deployment that keeps its
183
+ # conversations forever must still expire the reports (the memory-TTL
184
+ # reasoning). -> Integer (removed) | nil (no knob, or the claim is held).
185
+ def sweep_artifacts
186
+ ttl = artifact_ttl_days
187
+ return nil if ttl.nil? || ttl <= 0
188
+ return nil unless claim(ARTIFACT_TTL_KEY)
189
+
190
+ @artifact_store.delete_older_than(now - ttl * 86_400)
191
+ end
192
+
193
+ # settings["artifact_ttl_days"]: Integer days. nil/blank/non-numeric -> nil
194
+ # (OFF — parity, never a crash at sweep time).
195
+ def artifact_ttl_days
196
+ return nil unless @settings_store
197
+
198
+ value = @settings_store.get["artifact_ttl_days"]
199
+ value.to_s.empty? ? nil : Integer(value)
200
+ rescue ArgumentError, TypeError
201
+ nil
202
+ end
203
+
169
204
  # -> [[scope, Time]] — one per existing cell with a resolved TTL. The
170
205
  # setting is passed in (one settings-store read per sweep — the caller
171
206
  # already resolved it).
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/request"
5
+ require "async/http/client"
6
+ require "async/http/endpoint"
7
+ require "protocol/http/body/buffered"
8
+ require_relative "session_key"
9
+ require_relative "proxy_body"
10
+
11
+ module Insika
12
+ module Router
13
+ # The standalone Rack/Async app: session-key extraction →
14
+ # consistent-hash pick → proxy, on the same Async/Falcon stack the engine
15
+ # already runs on. A request whose key has no live owner (nothing found,
16
+ # or the ring itself is empty) round-robins; a request whose chosen
17
+ # backend is unreachable answers the retry envelope — it is NOT
18
+ # retried against a different backend (§3.5): that backend may already
19
+ # hold a durable, at-most-once claim on the task this request names.
20
+ class App
21
+ DEFAULT_BODY_MAX_BYTES = 262_144 # 256 KiB — small JSON control payloads, never uploads (§5)
22
+ RETRY_AFTER_SECONDS = 1
23
+ HOP_BY_HOP = %w[connection keep-alive proxy-connection transfer-encoding upgrade host content-length].freeze
24
+
25
+ # client_factory: (backend_url, timeout) -> an object answering #call(request)
26
+ # -> Protocol::HTTP::Response. Defaults to a real Async::HTTP::Client;
27
+ # a spec injects a fake instead of opening real sockets.
28
+ def initialize(pool:, body_max_bytes: DEFAULT_BODY_MAX_BYTES, backend_timeout: 10, logger: $stdout,
29
+ client_factory: DEFAULT_CLIENT_FACTORY)
30
+ @pool = pool
31
+ @body_max_bytes = body_max_bytes
32
+ @backend_timeout = backend_timeout
33
+ @logger = logger
34
+ @client_factory = client_factory
35
+ @clients = {} # backend address -> memoized client (persistent connections)
36
+ @clients_mutex = Mutex.new
37
+ @rr_index = -1
38
+ end
39
+
40
+ DEFAULT_CLIENT_FACTORY = lambda do |backend, timeout|
41
+ Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(backend, timeout: timeout))
42
+ end
43
+ private_constant :DEFAULT_CLIENT_FACTORY
44
+
45
+ def call(env)
46
+ req = Rack::Request.new(env)
47
+ segments = req.path_info.split("/").reject(&:empty?)
48
+
49
+ return health_response if req.request_method == "GET" && segments == ["up"]
50
+
51
+ raw_body = read_body(env)
52
+ backend = pick_backend(req.request_method, segments, raw_body)
53
+ return unavailable_response if backend.nil?
54
+
55
+ proxy(req, backend, raw_body)
56
+ end
57
+
58
+ private
59
+
60
+ def pick_backend(method, segments, raw_body)
61
+ backends = @pool.backends
62
+ return nil if backends.empty?
63
+
64
+ key = session_key(method, segments, raw_body)
65
+ return @pool.ring.backend_for(key) if key
66
+
67
+ @rr_index = (@rr_index + 1) % backends.size
68
+ backends[@rr_index]
69
+ end
70
+
71
+ def session_key(method, segments, raw_body)
72
+ return nil if raw_body.nil?
73
+
74
+ if raw_body.bytesize > @body_max_bytes
75
+ log("router: body #{raw_body.bytesize}B exceeds body_max_bytes=#{@body_max_bytes} — " \
76
+ "skipping session-key extraction (round-robin), forwarding it whole regardless")
77
+ return nil
78
+ end
79
+
80
+ SessionKey.extract(method, segments, body: -> { JSON.parse(raw_body) })
81
+ end
82
+
83
+ # Reads the WHOLE body (it must be forwarded intact) — `body_max_bytes`
84
+ # only bounds how much of it #session_key will try to parse as JSON,
85
+ # never how much reaches the backend (§3.1, §5).
86
+ def read_body(env)
87
+ input = env["rack.input"]
88
+ return nil if input.nil?
89
+
90
+ data = input.read
91
+ input.rewind if input.respond_to?(:rewind)
92
+ data.nil? || data.empty? ? nil : data
93
+ end
94
+
95
+ def proxy(req, backend, raw_body)
96
+ client = client_for(backend)
97
+ path = req.script_name.to_s + req.path_info
98
+ path += "?#{req.query_string}" unless req.query_string.to_s.empty?
99
+ body = raw_body ? Protocol::HTTP::Body::Buffered.wrap(raw_body) : nil
100
+
101
+ response = client.call(
102
+ Protocol::HTTP::Request.new(nil, nil, req.request_method, path, nil,
103
+ forward_headers(req), body)
104
+ )
105
+ [response.status, response_headers(response), response.body ? ProxyBody.new(response.body) : []]
106
+ rescue StandardError => e
107
+ log("router: backend #{backend} unreachable (#{e.class}: #{e.message})")
108
+ unavailable_response
109
+ end
110
+
111
+ def client_for(backend)
112
+ @clients_mutex.synchronize { @clients[backend] ||= @client_factory.call(backend, @backend_timeout) }
113
+ end
114
+
115
+ def forward_headers(req)
116
+ headers = Protocol::HTTP::Headers.new
117
+ req.each_header do |key, value|
118
+ next unless key.start_with?("HTTP_")
119
+
120
+ name = key.sub(/\AHTTP_/, "").tr("_", "-").downcase
121
+ headers.add(name, value) unless HOP_BY_HOP.include?(name)
122
+ end
123
+ headers.add("content-type", req.content_type) if req.content_type
124
+ headers
125
+ end
126
+
127
+ def response_headers(response)
128
+ headers = {}
129
+ response.headers&.each do |key, value|
130
+ name = key.to_s
131
+ headers[name] = headers.key?(name) ? Array(headers[name]) + [value] : value
132
+ end
133
+ headers
134
+ end
135
+
136
+ def health_response
137
+ json_response(200, { status: "ok", backends: @pool.backends.size })
138
+ end
139
+
140
+ def unavailable_response
141
+ json_response(503, { error: { class: "Insika::Router::BackendUnavailable",
142
+ message: "no backend reachable",
143
+ retryable: true, retry_after: RETRY_AFTER_SECONDS } })
144
+ end
145
+
146
+ def json_response(status, body)
147
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
148
+ end
149
+
150
+ def log(message)
151
+ @logger&.puts(message)
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require_relative "hash_ring"
5
+
6
+ module Insika
7
+ module Router
8
+ # Backend discovery + the ring it feeds. Two modes:
9
+ #
10
+ # static — a fixed list, resolved once (the Railway shape: N local
11
+ # Falcon workers on known ports inside one container).
12
+ # dns — one hostname re-resolved on an interval (the Kubernetes
13
+ # shape: a headless Service, one A/AAAA record per ready pod).
14
+ #
15
+ # The ring is rebuilt ONLY when the resolved set actually changed — a DNS
16
+ # poll that returns the same pods is a no-op, not a ring rebuild every
17
+ # `dns_interval` seconds.
18
+ class BackendPool
19
+ def initialize(static: nil, dns: nil, dns_port: nil, dns_interval: 15,
20
+ replicas: HashRing::DEFAULT_REPLICAS, resolver: Resolv, logger: $stdout)
21
+ raise ArgumentError, "static or dns is required, not both" if static.nil? == dns.nil?
22
+ raise ArgumentError, "dns_port is required with dns:" if dns && dns_port.nil?
23
+ raise ArgumentError, "static must not be empty" if static && Array(static).empty?
24
+
25
+ @static = Array(static)
26
+ @dns = dns
27
+ @dns_port = dns_port
28
+ @dns_interval = dns_interval
29
+ @replicas = replicas
30
+ @resolver = resolver
31
+ @logger = logger
32
+ @mutex = Mutex.new
33
+ @ring = nil
34
+ refresh!
35
+ end
36
+
37
+ def dns? = !@dns.nil?
38
+
39
+ def ring
40
+ @mutex.synchronize { @ring }
41
+ end
42
+
43
+ def backends
44
+ @mutex.synchronize { @ring&.backends || [] }
45
+ end
46
+
47
+ # Re-resolves (a no-op for static after the first call) and rebuilds
48
+ # the ring iff the resolved set changed. -> bool (did it change?).
49
+ def refresh!
50
+ resolved = @dns ? resolve_dns : @static
51
+ if resolved.empty?
52
+ log("router: 0 backends resolved — keeping the previous ring" \
53
+ "#{" (#{@ring.backends.size} backend(s))" if @ring}")
54
+ return false
55
+ end
56
+
57
+ @mutex.synchronize do
58
+ return false if @ring && @ring.backends.sort == resolved.sort
59
+
60
+ @ring = HashRing.new(resolved, replicas: @replicas)
61
+ end
62
+ log("router: ring rebuilt — #{resolved.sort.join(', ')}")
63
+ true
64
+ end
65
+
66
+ # Starts the periodic re-resolve loop (dns mode only — a no-op for
67
+ # static) inside the caller's Async task, so a spec can drive #refresh!
68
+ # directly without ever starting this loop.
69
+ def start_polling(task)
70
+ return unless dns?
71
+
72
+ task.async do |t|
73
+ loop do
74
+ t.sleep(@dns_interval)
75
+ refresh!
76
+ rescue StandardError => e
77
+ log("router: DNS re-resolve failed (#{e.class}: #{e.message}) — keeping the previous ring")
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def resolve_dns
85
+ @resolver.getaddresses(@dns).map { |ip| "http://#{ip.include?(':') ? "[#{ip}]" : ip}:#{@dns_port}" }
86
+ rescue StandardError => e
87
+ log("router: DNS resolve of #{@dns} failed (#{e.class}: #{e.message})")
88
+ []
89
+ end
90
+
91
+ def log(message)
92
+ @logger&.puts(message)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Insika
6
+ module Router
7
+ # Ketama-style consistent hash ring. Each backend gets
8
+ # `replicas` virtual points on a 0..2**32-1 circle (CRC32 of "backend#i");
9
+ # a key's owner is the first point clockwise from CRC32(key). Removing or
10
+ # adding one backend only remaps the ~1/N of the space that belonged to
11
+ # that backend's own points — not the whole ring — which is what keeps a
12
+ # rolling deploy from bouncing every live session to a new owner at once.
13
+ class HashRing
14
+ DEFAULT_REPLICAS = 160
15
+
16
+ def initialize(backends, replicas: DEFAULT_REPLICAS)
17
+ raise ArgumentError, "at least one backend is required" if Array(backends).empty?
18
+
19
+ @replicas = replicas
20
+ @backends = backends.uniq.sort
21
+ @ring = {}
22
+ @backends.each do |backend|
23
+ @replicas.times { |i| @ring[Zlib.crc32("#{backend}\0#{i}")] = backend }
24
+ end
25
+ @points = @ring.keys.sort
26
+ end
27
+
28
+ attr_reader :backends, :points, :ring
29
+
30
+ # -> the backend owning `key` — the first ring point at or after
31
+ # CRC32(key), wrapping around to the first point when `key` hashes past
32
+ # the last one. O(log N) via binary search, not a hash-map rebuild.
33
+ def backend_for(key)
34
+ backend_for_point(Zlib.crc32(key.to_s))
35
+ end
36
+
37
+ # Fraction of a representative KEY SAMPLE whose owner changes between
38
+ # two rings — the measurement acceptance §6.2 asks for, not an
39
+ # assumption. (Ring POINTS themselves are the wrong yardstick: a's/b's/
40
+ # c's/d's own points stay put when "e" is added — only the space of
41
+ # arbitrary keys BETWEEN points shifts.) `sample_size` large enough that
42
+ # the law of large numbers keeps the estimate tight without a spec
43
+ # needing thousands of literal keys of its own.
44
+ def self.remapped_fraction(before, after, sample_size: 20_000)
45
+ moved = (0...sample_size).count { |i| before.backend_for(i) != after.backend_for(i) }
46
+ moved.to_f / sample_size
47
+ end
48
+
49
+ def backend_for_point(point)
50
+ idx = @points.bsearch_index { |p| p >= point } || 0
51
+ @ring[@points[idx]]
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Router
5
+ # Streams an upstream `Protocol::HTTP::Response` body back to the
6
+ # downstream client. Exposes `#call(stream)`, NOT `#each` — the same
7
+ # discovery `Server::SSEBody` documents: under protocol-rack/protocol-http1
8
+ # (the stack of Async::HTTP::Server AND Falcon), a body that only responds
9
+ # to `#each` is routed to `Body::Enumerable`, whose `read` runs the `#each`
10
+ # in a plain Enumerator Fiber where `Async::Task.current` is unavailable —
11
+ # so a long-running SSE turn proxied through this router would come out
12
+ # empty. `#call` routes it to `Body::Streaming` instead, scheduled via
13
+ # `Fiber.schedule` under the reactor, which is what makes an SSE stream
14
+ # drain through the router with no added buffering beyond the one-time
15
+ # request-body read (§6.5).
16
+ class ProxyBody
17
+ def initialize(upstream_body)
18
+ @upstream_body = upstream_body
19
+ end
20
+
21
+ def call(stream)
22
+ @upstream_body.each { |chunk| stream.write(chunk) }
23
+ rescue StandardError
24
+ # The downstream client disconnected, or the upstream connection
25
+ # dropped mid-stream: no exception escapes (same rule as SSEBody) —
26
+ # the turn itself belongs to the backend, not this connection.
27
+ nil
28
+ ensure
29
+ @upstream_body.close
30
+ stream.close
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Router
5
+ # Session-key extraction, matched to the routes that
6
+ # actually carry production traffic in `Server::App` today. The RFC's own
7
+ # sketch of a path-based `/api/widget/sessions/:token/…` route was written
8
+ # from memory and does not match what `server/app.rb` implements — the
9
+ # widget/relay surface is `POST /channels/:id/messages` (and `/events`),
10
+ # with the session id as `session_id` in the JSON body, not a path
11
+ # segment. `/v1/responses` and `/v1/messages` carry it as `user`. Every
12
+ # other route (health checks, `/studio/*`, onboarding) has no session key
13
+ # and round-robins — none of them depend on a worker's in-memory
14
+ # `SessionActor` (§3.1 point 3).
15
+ module SessionKey
16
+ BODY_FIELD_BY_ROUTE = {
17
+ %w[v1 responses] => "user",
18
+ %w[v1 messages] => "user"
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ # segments: the request path split on "/" with empty parts removed.
24
+ # body: a zero-arg callable returning the parsed JSON body (a Hash) —
25
+ # called AT MOST ONCE, and only when a route that carries a session key
26
+ # actually matches, so a GET or an unrelated POST never pays for a
27
+ # parse. A malformed body yields no key (the backend's own parser is
28
+ # what answers the client's 400/422), never a router-level error.
29
+ def extract(method, segments, body:)
30
+ return nil unless method == "POST"
31
+
32
+ field =
33
+ if segments.length == 3 && segments[0] == "channels" && %w[messages events].include?(segments[2])
34
+ "session_id"
35
+ else
36
+ BODY_FIELD_BY_ROUTE[segments]
37
+ end
38
+ return nil unless field
39
+
40
+ read_field(body, field)
41
+ end
42
+
43
+ def read_field(body, field)
44
+ parsed = body.call
45
+ return nil unless parsed.is_a?(Hash)
46
+
47
+ value = parsed[field] || parsed[field.to_sym]
48
+ Insika::Coercion.present?(value) ? value.to_s : nil
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Session-sticky router. NOT required by `require "insika"` (like
4
+ # server/ and the Studio, it pulls in async-http and is only needed by an
5
+ # operator who actually runs `bin/insika-router`): the engine's serving path
6
+ # (`insika serve` / `DSL::ServerBoot` / `WEB_CONCURRENCY=1`) is completely
7
+ # unaffected by this file's existence — the router is additive infrastructure
8
+ # for whoever chooses to scale past one worker, never a default.
9
+ require_relative "coercion"
10
+ require_relative "router/hash_ring"
11
+ require_relative "router/session_key"
12
+ require_relative "router/backend_pool"
13
+ require_relative "router/app"
14
+
15
+ module Insika
16
+ module Router
17
+ end
18
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # the parsed recurring-schedule declaration of ONE agent —
5
+ # the ONLY shape the engine accepts, shared by the ScheduleEngine, the
6
+ # doctor and the Studio. Pure value object (the followup-policy precedent).
7
+ #
8
+ # One schedule: a name, a trigger (`cron` OR `every`, never both), a
9
+ # timezone for cron materialization, the synthetic inbound message, a
10
+ # session mode (a fresh session per run, or a standing one), per-run
11
+ # overrides (turn_timeout / max_tool_calls / model) and an enabled flag.
12
+ #
13
+ # `parse` returns nil on a malformed hash (the engine SKIPS, the doctor
14
+ # explains, the Studio refuses); `parse!` raises Insika::ValidationError
15
+ # naming the exact defect.
16
+ class Schedule
17
+ SESSION_MODES = %w[new fixed].freeze
18
+ OVERRIDE_KEYS = %w[turn_timeout max_tool_calls model].freeze
19
+ ID_RE = /\A[a-z][a-z0-9_-]*\z/
20
+
21
+ attr_reader :id, :every, :cron, :tz, :message, :session_mode, :session_id,
22
+ :overrides, :enabled
23
+
24
+ def self.parse(hash)
25
+ new(hash)
26
+ rescue Insika::ValidationError
27
+ nil
28
+ end
29
+
30
+ def self.parse!(hash)
31
+ new(hash)
32
+ end
33
+
34
+ def initialize(hash)
35
+ raise Insika::ValidationError, "schedule: declaration must be a Hash" unless hash.is_a?(Hash)
36
+
37
+ h = hash.transform_keys(&:to_s)
38
+ @id = id_of(h)
39
+ @every = every_of(h)
40
+ @cron = cron_of(h)
41
+ if @cron.nil? && @every.nil?
42
+ raise Insika::ValidationError,
43
+ "schedule '#{@id}': a trigger is required — declare cron or every"
44
+ end
45
+ @tz = tz_of(h)
46
+ @message = message_of(h)
47
+ @session_mode = session_mode_of(h)
48
+ @session_id = session_id_of(h)
49
+ @overrides = overrides_of(h)
50
+ @enabled = h.key?("enabled") ? h["enabled"] == true : true
51
+ freeze
52
+ end
53
+
54
+ def cron? = !@cron.nil?
55
+ def every? = !@every.nil?
56
+ def fixed_session? = @session_mode == "fixed"
57
+
58
+ def to_h
59
+ { "id" => @id, "cron" => @cron, "every" => @every, "tz" => @tz,
60
+ "message" => @message, "session_mode" => @session_mode,
61
+ "session_id" => @session_id, "overrides" => @overrides,
62
+ "enabled" => @enabled }.compact
63
+ end
64
+
65
+ private
66
+
67
+ def id_of(h)
68
+ id = h["id"].to_s.strip.downcase
69
+ unless ID_RE.match?(id)
70
+ raise Insika::ValidationError,
71
+ "schedule.id must match #{ID_RE.inspect}, got: #{h['id'].inspect}"
72
+ end
73
+
74
+ id
75
+ end
76
+
77
+ def every_of(h)
78
+ every = h["every"]
79
+ return nil if every.nil?
80
+
81
+ unless every.is_a?(Integer) && every.positive?
82
+ raise Insika::ValidationError,
83
+ "schedule.every must be a positive integer of seconds, got: #{h['every'].inspect}"
84
+ end
85
+
86
+ every
87
+ end
88
+
89
+ def cron_of(h)
90
+ cron = h["cron"]
91
+ return nil if cron.nil?
92
+
93
+ if h.key?("every") && !h["every"].nil?
94
+ raise Insika::ValidationError,
95
+ "schedule '#{@id}': cron and every are mutually exclusive — declare exactly one trigger"
96
+ end
97
+
98
+ Insika::Cron.new(cron) # raises ValidationError on a malformed expression
99
+ cron.to_s
100
+ end
101
+
102
+ def tz_of(h)
103
+ tz = h["tz"].to_s
104
+ tz = "Etc/UTC" if tz.empty?
105
+ unless Insika::Timezone.known?(tz)
106
+ raise Insika::ValidationError, "schedule '#{@id}'.tz is not a valid IANA timezone: #{tz.inspect}"
107
+ end
108
+
109
+ tz
110
+ end
111
+
112
+ def message_of(h)
113
+ message = h["message"]
114
+ if Coercion.blank?(message)
115
+ raise Insika::ValidationError,
116
+ "schedule '#{@id}'.message is required — the synthetic inbound that kicks each run"
117
+ end
118
+
119
+ message.to_s
120
+ end
121
+
122
+ def session_mode_of(h)
123
+ mode = h["session_mode"]
124
+ return "new" if mode.nil?
125
+
126
+ mode = mode.to_s
127
+ unless SESSION_MODES.include?(mode)
128
+ raise Insika::ValidationError,
129
+ "schedule '#{@id}'.session_mode must be one of #{SESSION_MODES.inspect}, got: #{h['session_mode'].inspect}"
130
+ end
131
+
132
+ mode
133
+ end
134
+
135
+ def session_id_of(h)
136
+ session_id = h["session_id"]
137
+ return nil if session_id.nil?
138
+
139
+ session_id.to_s
140
+ end
141
+
142
+ def overrides_of(h)
143
+ overrides = h["overrides"]
144
+ return nil if overrides.nil?
145
+
146
+ raise Insika::ValidationError, "schedule '#{@id}'.overrides must be a Hash" unless overrides.is_a?(Hash)
147
+
148
+ overrides = overrides.transform_keys(&:to_s)
149
+ unknown = overrides.keys - OVERRIDE_KEYS
150
+ unless unknown.empty?
151
+ raise Insika::ValidationError,
152
+ "schedule '#{@id}'.overrides: unknown key(s) #{unknown.inspect} " \
153
+ "(allowed: #{OVERRIDE_KEYS.join(', ')})"
154
+ end
155
+
156
+ overrides.each do |key, value|
157
+ # model is a provider/model REF — validated at declaration, not left
158
+ # for model-resolution time; everything else is an integer ceiling.
159
+ if key == "model"
160
+ if !value.is_a?(String) || value.strip.empty?
161
+ raise Insika::ValidationError,
162
+ "schedule '#{@id}'.overrides.model must be a non-blank String (a " \
163
+ "model ref), got: #{value.inspect}"
164
+ end
165
+ next
166
+ end
167
+
168
+ unless value.is_a?(Integer) && value.positive?
169
+ raise Insika::ValidationError,
170
+ "schedule '#{@id}'.overrides.#{key} must be a positive Integer, got: #{value.inspect}"
171
+ end
172
+ end
173
+
174
+ overrides
175
+ end
176
+ end
177
+ end