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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
data/lib/insika/retention.rb
CHANGED
|
@@ -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
|