insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "assertions"
7
+
8
+ module Insika
9
+ module Evals
10
+ # How the runner turns a (agent, conversation, message) into a TurnResult. The
11
+ # runner depends only on this seam, so it's unit-testable with a fake — the real
12
+ # HttpTransport is exercised against a running insika.
13
+ #
14
+ # One turn's outcome: the TurnResult the engine asserts on + perf timing (ttfb/
15
+ # total in ms) so `--mode perf` reports real latency over the real corpus (#6b).
16
+ #
17
+ # `usage` is the turn's token counts as the deployment reported them
18
+ # (`response.completed`), or nil when the provider sent none. It is carried, never
19
+ # asserted on: the consumer is's refinement budget, which has to bound the
20
+ # cost of a gate replay and cannot invent the number. nil is preserved as nil
21
+ # rather than zeroed — "the provider did not say" and "it cost nothing" are
22
+ # different facts, and a budget that confuses them stops being a budget.
23
+ TurnOutcome = Struct.new(:result, :ttfb, :total, :usage, keyword_init: true)
24
+
25
+ # Pure reduction of the /v1/responses SSE stream. Kept separate from the HTTP so
26
+ # it's testable offline with canned frames (server/responses.rb is the producer).
27
+ module Sse
28
+ module_function
29
+
30
+ # Raw SSE text -> [parsed JSON payload]. Skips `event:` lines, blanks, [DONE].
31
+ def payloads(text)
32
+ text.to_s.split("\n\n").flat_map do |frame|
33
+ frame.each_line.filter_map do |line|
34
+ next unless line.start_with?("data:")
35
+
36
+ p = line.sub(/^data:\s*/, "").strip
37
+ next if p.empty? || p == "[DONE]"
38
+
39
+ begin
40
+ JSON.parse(p)
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # [payload] -> { output_text:, tool_calls:, usage:, error: }. Maps the Responses
49
+ # frames (see server/responses.rb#frame_for): text deltas accumulate; each
50
+ # function_call item contributes a tool NAME (this stream carries no per-tool
51
+ # status — that lives in the ToolTraceStore, an in-process enrichment); a
52
+ # `response.failed` sets the turn error.
53
+ def reduce(payloads)
54
+ text = +""
55
+ tools = []
56
+ usage = nil
57
+ error = nil
58
+ payloads.each do |o|
59
+ case o["type"]
60
+ when "response.output_text.delta"
61
+ text << o["delta"].to_s
62
+ when "response.output_item.added"
63
+ item = o["item"] || {}
64
+ tools << { "name" => item["name"].to_s, "status" => nil } if item["type"] == "function_call"
65
+ when "response.completed"
66
+ usage = o.dig("response", "usage")
67
+ when "response.failed"
68
+ error = o.dig("response", "error", "message") || "response.failed"
69
+ end
70
+ end
71
+ { output_text: text, tool_calls: tools, usage: usage, error: error }
72
+ end
73
+ end
74
+
75
+ # What the deployment HAS, per agent, read over the same gated
76
+ # `/v1` the replay uses. The eval stays a client: it asks the engine instead of
77
+ # keeping its own idea of which tools exist.
78
+ #
79
+ # `#for(id)` -> { "tools" => [names] | nil, "capabilities" => [names] } | nil.
80
+ # nil means UNRESOLVED — the agent is not there, the route is not wired, the
81
+ # deployment is unreachable — and the Runner treats that as "run the case
82
+ # anyway". Silence must not shrink a suite. Answers are memoized: a corpus has
83
+ # many cases per agent and this is the same answer every time.
84
+ class HttpCapabilities
85
+ def initialize(base_url:, token:, timeout: 10)
86
+ @base = base_url
87
+ @token = token
88
+ @timeout = timeout
89
+ @cache = {}
90
+ end
91
+
92
+ def for(agent_id)
93
+ @cache.fetch(agent_id) { @cache[agent_id] = fetch(agent_id) }
94
+ end
95
+
96
+ private
97
+
98
+ def fetch(agent_id)
99
+ uri = URI.join("#{@base}/", "v1/agents/#{URI.encode_www_form_component(agent_id)}")
100
+ req = Net::HTTP::Get.new(uri)
101
+ req["Authorization"] = "Bearer #{@token}"
102
+
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = uri.scheme == "https"
105
+ http.read_timeout = @timeout
106
+ http.open_timeout = @timeout
107
+ res = http.start { http.request(req) }
108
+ return nil unless res.code.to_i == 200
109
+
110
+ body = JSON.parse(res.body)
111
+ { "tools" => body["tools"], "capabilities" => Array(body["capabilities"]) }
112
+ rescue StandardError, JSON::ParserError
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Drives real turns over POST /v1/responses — the SAME surface as
118
+ # scripts/loadtest.rb, so `--mode perf` is apples-to-apples with the loadtest and
119
+ # closes the real-traffic gap (#6b). A conversation is keyed by `user` (the
120
+ # adapter continues an existing chat on the same id), so multi-turn goldens replay
121
+ # in order under one `conv` id.
122
+ class HttpTransport
123
+ def initialize(base_url:, token:, timeout: 120)
124
+ @base = base_url
125
+ @token = token
126
+ @timeout = timeout
127
+ end
128
+
129
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+
131
+ def turn(agent:, conv:, message:)
132
+ uri = URI.join("#{@base}/", "v1/responses")
133
+ req = Net::HTTP::Post.new(uri)
134
+ req["Authorization"] = "Bearer #{@token}"
135
+ req["Content-Type"] = "application/json"
136
+ req["Accept"] = "text/event-stream"
137
+ req.body = JSON.generate(model: "openclaw:#{agent}", user: conv, stream: true, input: message)
138
+
139
+ t0 = mono
140
+ ttfb = nil
141
+ buffer = +""
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = uri.scheme == "https"
144
+ http.read_timeout = @timeout
145
+ http.open_timeout = 10
146
+
147
+ status = nil
148
+ http.start do
149
+ http.request(req) do |res|
150
+ status = res.code.to_i
151
+ return failure("HTTP #{status}", t0) unless status.between?(200, 299)
152
+
153
+ res.read_body do |chunk|
154
+ ttfb ||= (mono - t0) * 1000.0
155
+ buffer << chunk
156
+ end
157
+ end
158
+ end
159
+
160
+ reduced = Sse.reduce(Sse.payloads(buffer))
161
+ TurnOutcome.new(
162
+ result: TurnResult.new(output_text: reduced[:output_text],
163
+ tool_calls: reduced[:tool_calls], error: reduced[:error]),
164
+ ttfb: ttfb, total: (mono - t0) * 1000.0, usage: reduced[:usage]
165
+ )
166
+ rescue StandardError => e
167
+ failure(e.class.to_s, t0)
168
+ end
169
+
170
+ private
171
+
172
+ def failure(msg, t0)
173
+ TurnOutcome.new(result: TurnResult.new(output_text: "", tool_calls: [], error: msg),
174
+ ttfb: nil, total: (mono - t0) * 1000.0)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Everything the runtime emits is an Event: a closed canonical catalog — new
5
+ # types require updating the catalog.
6
+ #
7
+ # meta: { task_id:, session_id:, seq:, at: } (seq monotonic per task).
8
+ # Terminal turn events are :task_completed / :task_failed / :task_cancelled.
9
+ # :error is NOT a terminal twin — it survives only as the subscription-overflow
10
+ # signal (EventStream) and the post-terminal failure report (fail_task).
11
+ Event = Data.define(:type, :data, :meta) do
12
+ def initialize(type:, data:, meta: {})
13
+ super
14
+ end
15
+
16
+ def to_h = { type:, **data, meta: meta.compact }
17
+ end
18
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module Insika
7
+ # In-process pub/sub. The stream is concurrent with the
8
+ # turn: a slow observer NEVER delays execution — each subscriber
9
+ # has its own queue and `emit` only enqueues. No mutex: one reactor, cooperative
10
+ # fibers — a plain Array is enough.
11
+ class EventStream
12
+ # One subscription = one queue. The consumer blocks on `each` (its own fiber),
13
+ # never the emitter.
14
+ class Subscription
15
+ CLOSED = Object.new # internal end-of-stream sentinel
16
+ private_constant :CLOSED
17
+
18
+ # Cap on events queued per subscriber. A slow consumer
19
+ # piles up in its OWN queue; on overflow, the subscription closes with a
20
+ # local :error event — the turn never waits on transport.
21
+ MAX_QUEUED = 1000
22
+
23
+ def initialize(task_id: nil, session_id: nil, tenant: nil, types: nil, on_close: nil)
24
+ @task_id = task_id
25
+ @session_id = session_id
26
+ @tenant = tenant
27
+ @types = types
28
+ @on_close = on_close
29
+ @queue = Async::Queue.new
30
+ end
31
+
32
+ # Binds the subscription to a task_id AFTER creation: the
33
+ # transport subscribes before the dispatch — when the task_id does not yet
34
+ # exist — and binds it as soon as the handler returns. This keeps the
35
+ # per-task cap honest (only events for THIS task enter the queue) and makes
36
+ # the overflow :error carry the correct task_id. Returns self to chain.
37
+ def bind(task_id:)
38
+ @task_id = task_id
39
+ self
40
+ end
41
+
42
+ # Meta filter: nil = matches any value. Events with no task_id in
43
+ # meta (e.g. :session_created) reach only subscribers with no task filter.
44
+ #
45
+ # A TENANT-scoped subscription is FAIL-CLOSED on the meta's tenant (WS1):
46
+ # an event that does not carry the tenant (control events, ignored turns)
47
+ # matches NO tenant subscription. The tenant only ever sees its own.
48
+ #
49
+ # `types:` (nil = any) keeps a subscriber's queue to the events it answers
50
+ # — an alert consumer must not sit behind a full-traffic stream's 1000-cap
51
+ # (WS6), and a filtered queue is the cheapest way to keep it there.
52
+ def matches?(event)
53
+ meta = event.meta || {}
54
+ owned = @tenant.nil? || meta[:tenant] == @tenant
55
+
56
+ owned &&
57
+ (@types.nil? || @types.include?(event.type)) &&
58
+ (@task_id.nil? || meta[:task_id] == @task_id) &&
59
+ (@session_id.nil? || meta[:session_id] == @session_id)
60
+ end
61
+
62
+ # Enqueues without EVER blocking. The real queue depth is `@queue.size`
63
+ # (not a separate counter — avoids drift). On reaching the cap, it enqueues
64
+ # a local :error and closes; pushes after close are ignored (`@closed`).
65
+ def push(event)
66
+ return if @closed
67
+
68
+ if @queue.size >= MAX_QUEUED
69
+ @queue.enqueue(Insika::Event.new(
70
+ type: :error,
71
+ data: { message: "subscription overflow" },
72
+ meta: { task_id: @task_id, session_id: @session_id }
73
+ ))
74
+ close
75
+ return
76
+ end
77
+
78
+ @queue.enqueue(event)
79
+ end
80
+
81
+ # Blocks the CONSUMER's fiber until #close.
82
+ def each
83
+ while (event = @queue.dequeue) != CLOSED
84
+ yield event
85
+ end
86
+ end
87
+
88
+ # Idempotent: a second CLOSED is harmless (the `each` stops at the first).
89
+ # `@on_close` fires only once (avoids removing the subscription twice).
90
+ def close
91
+ return if @closed
92
+
93
+ @closed = true
94
+ @queue.enqueue(CLOSED)
95
+ @on_close&.call(self)
96
+ end
97
+ end
98
+
99
+ def initialize
100
+ @subscriptions = []
101
+ end
102
+
103
+ # NEVER raises: an observer's exception is isolated — a
104
+ # broken observer does not bring down the turn. Synchronous and cheap.
105
+ #
106
+ # Iterates over a SNAPSHOT (`dup`): a subscription's cap may close it
107
+ # DURING the push (overflow -> close -> on_close removes from the array);
108
+ # mutating the array in the middle of a plain Array#each would skip the next
109
+ # subscriber.
110
+ def emit(event)
111
+ @subscriptions.dup.each do |sub|
112
+ sub.push(event) if sub.matches?(event)
113
+ rescue StandardError
114
+ # a broken observer does not bring down the turn; nothing to propagate
115
+ end
116
+ nil
117
+ end
118
+
119
+ # nil/nil = all events. Returns the Subscription (the caller iterates with
120
+ # `#each` on its own fiber). `tenant:` scopes the stream to one tenant's
121
+ # events (WS1) — fail-closed, see Subscription#matches?. `types:` (nil =
122
+ # any) filters by event type so a subscriber's queue only ever holds what
123
+ # its consumer answers (WS6).
124
+ def subscribe(task_id: nil, session_id: nil, tenant: nil, types: nil)
125
+ sub = Subscription.new(task_id: task_id, session_id: session_id, tenant: tenant,
126
+ types: types,
127
+ on_close: ->(s) { @subscriptions.delete(s) })
128
+ @subscriptions << sub
129
+ sub
130
+ end
131
+ end
132
+ end