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,1022 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+ require "async"
6
+ require "securerandom"
7
+ require_relative "sse_body"
8
+ require_relative "tenant_auth" # WS1: Bearer -> { role:, tenant_id: } (single/multi-tenant)
9
+ require_relative "a2a/app" # A2A edge adapter (pulls protocol/errors/message/projection/card)
10
+ require_relative "responses" # OpenAI Responses adapter (/v1/responses) — drop-in for the OpenClaw gateway
11
+
12
+ module Insika
13
+ module Server
14
+ # Rack app. Transports ONLY
15
+ # translate requests into Commands — the server holds no business
16
+ # logic. It parses JSON, builds `Command.build(...)`, dispatches on the
17
+ # CommandBus and projects the Event Stream to SSE. Reads are NOT Commands: they are
18
+ # direct reads from the stores.
19
+ #
20
+ # AUDITABLE constitutional rule: `server/` does not import the Executor,
21
+ # store WRITE methods, or RubyLLM. Requires: json, rack, async and the
22
+ # core types (Command/Event/errors) already loaded by the composition root.
23
+ class App
24
+ SSE_HEADERS = {
25
+ "content-type" => "text/event-stream",
26
+ "cache-control" => "no-cache",
27
+ "connection" => "keep-alive"
28
+ }.freeze
29
+
30
+ # Terminal events of a turn (close the task subscription in the
31
+ # transport). The overflow :error self-closes its own subscription at the
32
+ # EventStream (enqueues CLOSED), so it needs no entry here to end `each`.
33
+ TERMINAL_EVENTS = %i[task_completed task_failed task_cancelled].freeze
34
+ private_constant :TERMINAL_EVENTS
35
+
36
+ # the `/v1` contract, versioned by date. A caller PINS behaviour
37
+ # with `Insika-Version: YYYY-MM-DD` so a future breaking change does not move
38
+ # silently underneath it; absent header = today's (only) version. Only one
39
+ # entry exists so far — the day a second one is added, the routes that
40
+ # changed branch on this value instead of being served whichever behaviour
41
+ # happened to be current.
42
+ KNOWN_VERSIONS = ["2026-08-08"].freeze
43
+ private_constant :KNOWN_VERSIONS
44
+
45
+ # the 500 envelope. A 500 is by definition unexpected — the client
46
+ # cannot fix the request, so the contract is "you may retry, wait this
47
+ # long, and quote this ref when you report it". The ref only means
48
+ # anything because the same line goes to the server log (see #internal_error_response).
49
+ RETRY_AFTER_SECONDS = 1
50
+ private_constant :RETRY_AFTER_SECONDS
51
+
52
+ # The operator control UI now lives in the Studio; server/ is a
53
+ # pure transport surface (/v1, /a2a). The constitutional rule holds: server/
54
+ # only READS stores and never imports the Executor, store writes, or RubyLLM.
55
+ def initialize(command_bus:, event_stream:, session_store:, task_store:,
56
+ config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
+ workflow_registry: nil, onboarding: nil, profiles: nil,
58
+ channels: nil, logger: nil, token_store: nil)
59
+ @command_bus = command_bus
60
+ @event_stream = event_stream
61
+ @session_store = session_store
62
+ @task_store = task_store
63
+ @config = config
64
+ @pending_action_store = pending_action_store # read for GET /v1/tasks/:id
65
+ @a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
66
+ @provisioner = provisioner # PackImporter. nil = provisioning not exposed.
67
+ # WS1 multi-tenant credentials. nil = single_tenant mode (the classic
68
+ # single operator credential, gateway_token). Present = tokens resolve
69
+ # from the store (per-tenant + operator), gateway_token still resolves
70
+ # as operator (an existing deployment switching modes keeps its token).
71
+ @token_store = token_store
72
+ # "single_tenant" (default, parity) | "multi_tenant".
73
+ @tenancy = config.fetch(:tenancy, "single_tenant")
74
+ # READ-ONLY registry, injected only where workflows are
75
+ # exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
76
+ # deployment does not expose workflows). Reading a catalog is a READ, like a
77
+ # store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
78
+ @workflow_registry = workflow_registry
79
+ # LLM-first onboarding surface (start.md + models.json +
80
+ # docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
81
+ # is that the developer's coding agent can fetch it), and READ-ONLY, so the
82
+ # constitutional rule holds. nil = routes not exposed (parity — the production
83
+ # deployment opts in). Reading files/masked stores is a READ, like a store read.
84
+ @onboarding = onboarding
85
+ # READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
86
+ # what an agent has — the eval is a client and cannot read a store. Same
87
+ # constitutional footing as the workflow registry: reading a catalog is a
88
+ # READ. nil = the route 404s (parity).
89
+ @profiles = profiles
90
+ # ONE generic route family for every channel, opt-in by
91
+ # injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
92
+ # The channel does the translating; this class keeps doing only transport.
93
+ @channels = channels
94
+ # where a 500's error_ref goes to be FOUND. nil = silent (parity for
95
+ # embedders); the serving wirings pass $stdout. Class+message+backtrace
96
+ # only — the ref never travels with request payloads (secrets stay out).
97
+ @logger = logger
98
+ @heartbeat = config.fetch(:heartbeat, 15)
99
+ @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
100
+ end
101
+
102
+ # Explicit routing, NO framework: ~10 routes in a `case`. A single
103
+ # `rescue` centralizes the error->status mapping. Only SYNCHRONOUS
104
+ # errors (before the fiber) become HTTP status; a task failure travels as
105
+ # an event on the stream and lands in GET /v1/tasks/:id.
106
+ def call(env)
107
+ req = Rack::Request.new(env)
108
+ route(req)
109
+ rescue JSON::ParserError => e
110
+ error_response(400, e) # malformed JSON, before any dispatch
111
+ rescue Insika::ValidationError => e
112
+ error_response(422, e)
113
+ rescue Insika::NotFoundError => e
114
+ error_response(404, e)
115
+ rescue Async::TimeoutError => e
116
+ error_response(504, e) # synchronous control request exceeded the ceiling
117
+ rescue StandardError => e
118
+ internal_error_response(e)
119
+ end
120
+
121
+ private
122
+
123
+ def route(req)
124
+ # UTF-8, not the ASCII-8BIT Rack hands us. A path segment becomes a STORE KEY
125
+ # (`/v1/agents/:id`, `/v1/sessions/:id`), and the sqlite3 driver binds a
126
+ # BINARY string as a BLOB — which never matches a TEXT column. So every such
127
+ # read answered 404 on a durable deployment while passing every spec, because
128
+ # the in-memory store is a Ruby Hash and a binary string is `eql?` to its
129
+ # UTF-8 twin. Found by calling `GET /v1/agents/:id` against a real database.
130
+ segments = req.path_info.split("/").reject(&:empty?).map { |s| Coercion.utf8(s) }
131
+ if segments.first == "v1"
132
+ version_error = version_gate(req)
133
+ return version_error if version_error
134
+ end
135
+
136
+ gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
137
+ return gate if gate
138
+ # WS1: a TENANT principal is confined to its own runtime surfaces (chat
139
+ # + its own reads). Every authoring/provisioning surface stays
140
+ # operator-only — a tenant can never mint tokens, author tools or
141
+ # change platform config. single_tenant (no principal) is untouched.
142
+ if tenant_principal?(req) && !tenant_surface?(req.request_method, segments)
143
+ return auth_error(403, "operator surface")
144
+ end
145
+
146
+ case [req.request_method, segments]
147
+ in ["GET", ["up"]]
148
+ health # readiness/liveness (Railway/k8s) — no auth, no store access
149
+ in ["GET", ["start.md"]] if @onboarding
150
+ markdown_response(200, @onboarding.start_md(base_url: public_base(req)))
151
+ in ["GET", ["models.json"]] if @onboarding
152
+ json_response(200, @onboarding.models_json(base_url: public_base(req)))
153
+ in ["GET", ["docs"]] if @onboarding
154
+ json_response(200, { docs: @onboarding.docs_index(base_url: public_base(req)) })
155
+ in ["GET", ["docs", file]] if @onboarding && file.end_with?(".md")
156
+ handle_doc(file)
157
+ in ["POST", ["v1", "commands", type]]
158
+ handle_command(req, type)
159
+ in ["POST", ["v1", "sessions"]]
160
+ handle_create_session(req)
161
+ in ["POST", ["v1", "messages"]]
162
+ handle_send_message(req)
163
+ in ["GET", ["v1", "workflows"]] if @workflow_registry
164
+ handle_list_workflows
165
+ in ["POST", ["v1", "workflows", name]] if @workflow_registry
166
+ handle_trigger_workflow(req, name)
167
+ in ["POST", ["v1", "responses"]]
168
+ handle_responses(req)
169
+ in ["POST", ["v1", "tools", "manifest"]]
170
+ handle_import_tools(req)
171
+ in ["POST", ["v1", "mcp", name, "import"]]
172
+ handle_import_mcp_tools(req, name)
173
+ in ["POST", ["v1", "agents"]] if @provisioner
174
+ handle_provision(req)
175
+ in ["DELETE", ["v1", "agents", id]] if @provisioner
176
+ handle_deprovision(req, id)
177
+ in ["GET", ["v1", "agents", id]] if @profiles
178
+ handle_read_agent(id)
179
+ in ["GET", ["v1", "sessions", id]]
180
+ handle_read_session(req, id)
181
+ in ["GET", ["v1", "tasks", id]]
182
+ handle_read_task(req, id)
183
+ in ["GET", ["v1", "events"]]
184
+ handle_events(req)
185
+ in ["POST", ["channels", id, "events"]] if @channels
186
+ handle_channel_event(req, id)
187
+ in ["POST", ["channels", id, "sessions"]] if @channels
188
+ handle_channel_session(req, id)
189
+ in ["POST", ["channels", id, "messages"]] if @channels
190
+ handle_channel_message(req, id)
191
+ in ["GET", ["channels", id, "asset", file]] if @channels
192
+ handle_channel_asset(req, id, file)
193
+ in ["OPTIONS", ["channels", id, *]] if @channels
194
+ handle_channel_preflight(req, id)
195
+ in ["POST", ["a2a"]] if @a2a
196
+ handle_a2a(req)
197
+ in ["GET", [".well-known", "agent-card.json"]] if @a2a
198
+ json_response(200, @a2a.agent_card)
199
+ else
200
+ not_found # wrong method/route (or A2A not exposed -> @a2a nil)
201
+ end
202
+ end
203
+
204
+ # The ONLY routes that answer without the gateway Bearer. Everything else is gated
205
+ # in `route`, before the dispatch — an ALLOWLIST, because the previous shape (each
206
+ # handler calling `gateway_gate` itself) is a rule you have to remember: the generic
207
+ # `POST /v1/commands/:type` never called it, so every authoring Command
208
+ # (`write_agent_file`, `upsert_llm_provider`, `delete_agent`…) was reachable by
209
+ # anyone who knew the URL, as were the session/task/event reads. A route added
210
+ # tomorrow is closed by default; making it public is now a deliberate edit here.
211
+ #
212
+ # `/up` is the health probe (no store access). The onboarding surface is opt-in
213
+ # (INSIKA_ONBOARDING) and exists to be read by a coding agent before it has any
214
+ # credential — turning it on is the operator choosing to publish it.
215
+ PUBLIC_ROUTES = [
216
+ ["GET", ["up"]],
217
+ ["GET", ["start.md"]],
218
+ ["GET", ["models.json"]],
219
+ ["GET", ["docs"]],
220
+ ["GET", [".well-known", "agent-card.json"]] # A2A discovery: the card is the ad
221
+ ].freeze
222
+
223
+ def public_route?(method, segments)
224
+ return true if PUBLIC_ROUTES.include?([method, segments])
225
+ return true if channel_route?(method, segments)
226
+
227
+ method == "GET" && segments.length == 2 && segments.first == "docs"
228
+ end
229
+
230
+ # A channel route skips the GATEWAY bearer because the channel authenticates
231
+ # it ITSELF — with the platform's own scheme (a relay's shared secret, a Slack
232
+ # HMAC signature, the widget's origin allowlist plus its mandatory rate limit),
233
+ # which is the only credential the caller has. Requiring the gateway token here
234
+ # instead would mean handing every platform — and every anonymous browser — a
235
+ # second secret it has no way to send.
236
+ #
237
+ # This is NOT an ungated route family: every handler below calls `channel_gate`
238
+ # before parsing anything, and a channel that implements no `authenticate`, or
239
+ # whose credential is unconfigured, answers `:disabled` rather than open.
240
+ # ENUMERATED rather than prefix-matched, so a route added to this family
241
+ # tomorrow is gated by default and publishing it is a deliberate edit here.
242
+ def channel_route?(method, segments)
243
+ case [method, segments]
244
+ in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
245
+ in ["GET", ["channels", _, "asset", _]] then true
246
+ in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
247
+ else false
248
+ end
249
+ end
250
+
251
+ # Bearer-gate error (503 disabled / 401 unauthorized), shared by the
252
+ # gateway surfaces. JSON body, fail-closed.
253
+ def auth_error(status, message, extra_headers = {})
254
+ [status,
255
+ { "content-type" => "application/json" }.merge(extra_headers),
256
+ [JSON.generate(error: { class: "Insika::Error", message: message })]]
257
+ end
258
+
259
+ # POST /v1/commands/:type — generic: every new Command is born with a
260
+ # transport. The control vs turn distinction is BY THE SHAPE of the result (the
261
+ # transport knows no semantics).
262
+ def handle_command(req, type)
263
+ command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http)
264
+ command_response(dispatch_with_timeout(command))
265
+ end
266
+
267
+ # POST /v1/sessions — sugar for create_session; 201 {session}.
268
+ def handle_create_session(req)
269
+ body = parse_body(req)
270
+ tenant = req_tenant(req)
271
+ # WS1: a tenant's session must be born under its OWN "<tenant>:" namespace
272
+ # — the read path (GET /v1/sessions/:id) refuses anything else. Scope the
273
+ # caller's id the same way message_flow scopes a session_id; a tenant that
274
+ # passed none gets a namespaced uuid instead of an unprefixed one it could
275
+ # never read back.
276
+ if tenant
277
+ id = body[:id] || body["id"]
278
+ id = Insika::Coercion.blank?(id) ? scoped_session_id(tenant, SecureRandom.uuid)
279
+ : scoped_session_id(tenant, id)
280
+ body = body.merge(id: id)
281
+ end
282
+ command = Insika::Command.build(:create_session,
283
+ { vars: body[:vars] || {}, id: body[:id] }.compact,
284
+ transport: :http, tenant: tenant)
285
+ session = dispatch_with_timeout(command)
286
+ json_response(201, { session: session.to_h })
287
+ end
288
+
289
+ # POST /v1/messages — sugar for send_message; ?stream missing/"true" -> SSE,
290
+ # "false" -> 200 JSON aggregated at the terminal event.
291
+ def handle_send_message(req)
292
+ stream = req.GET["stream"] != "false"
293
+ # only the aggregated-JSON form has room for the `merged`/`steered`
294
+ # verdict, so only it may join a message to another turn. Once the stream is open
295
+ # there is no way to tell the caller it does not own the reply.
296
+ message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
297
+ tenant: req_tenant(req))
298
+ end
299
+
300
+ # GET /docs/:name.md — one public doc as raw markdown. The
301
+ # slug is a KEY of the onboarding allowlist, so no filesystem traversal is
302
+ # possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
303
+ def handle_doc(file)
304
+ markdown = @onboarding.doc(file.sub(/\.md\z/, ""))
305
+ return not_found if markdown.nil?
306
+
307
+ markdown_response(200, markdown)
308
+ end
309
+
310
+ # Public base url for the interpolated onboarding links. Prefers an explicit
311
+ # config[:public_url] (behind a proxy/TLS terminator the request scheme is the
312
+ # internal http), else the request's own base_url.
313
+ def public_base(req)
314
+ Insika::Coercion.presence(@config[:public_url]) || req.base_url
315
+ end
316
+
317
+ # GET /v1/workflows — discovery. Direct read of the
318
+ # registry catalog (name + description + the I/O schema contract). Not a
319
+ # Command; opt-in via the injected registry.
320
+ def handle_list_workflows
321
+ json_response(200, { workflows: @workflow_registry.catalog })
322
+ end
323
+
324
+ # POST /v1/workflows/:name — triggers a workflow RUN. The name comes from the
325
+ # ROUTE; agent/input/session_id from the body. Two shapes:
326
+ # · default -> 202 { run_id, task_id } immediately (async at-most-once
327
+ # run; observe via GET /v1/tasks/:run_id or GET /v1/events?task_id=:run_id).
328
+ # · ?stream=true -> SSE of the run's events (incl. :workflow_started /
329
+ # :workflow_completed), closing at the terminal event.
330
+ # A bad input (input_schema) is a synchronous 422 with no run (WorkflowSchemaError
331
+ # -> ValidationError in #call); an unknown workflow/agent -> 404/422.
332
+ def handle_trigger_workflow(req, name)
333
+ body = parse_body(req)
334
+ payload = { workflow: name, agent: body[:agent],
335
+ input: body[:input], session_id: body[:session_id] }.compact
336
+ workflow_flow(payload, stream: req.GET["stream"] == "true",
337
+ tenant: req_tenant(req))
338
+ end
339
+
340
+ # POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
341
+ # gateway). Bearer via `config[:gateway_token]` (fail-closed). Translates
342
+ # the request -> :send_message and the turn's
343
+ # Event Stream -> OpenAI Responses SSE. Always streams (the consumer asks for SSE).
344
+ def handle_responses(req)
345
+ gate = gateway_gate(req)
346
+ return gate if gate
347
+
348
+ parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
349
+ tenant = req_tenant(req)
350
+ ensure_session(parsed[:user], tenant: tenant)
351
+ payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
352
+ payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
353
+ message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
354
+ tenant: tenant)
355
+ end
356
+
357
+ # POST /v1/agents — provisions (upserts) an agent from a standardized
358
+ # PACK. Same Bearer as /v1/responses (gateway_token,
359
+ # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
360
+ # JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
361
+ # Raw body (string keys): the pack's file/skill names are data keys,
362
+ # not symbols.
363
+ def handle_provision(req)
364
+ gate = gateway_gate(req)
365
+ return gate if gate
366
+
367
+ pack = Insika::Pack.from_h(parse_raw_body(req))
368
+ json_response(200, @provisioner.import(pack)) # Validation/NotFound -> 422/404 in #call
369
+ end
370
+
371
+ # POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
372
+ # Same Bearer as provisioning (gateway_token, fail-
373
+ # closed): it's an authoring/provisioning surface and resolves the
374
+ # deployment's secrets. RAW body (string keys): the JSON Schema property names and
375
+ # the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
376
+ # report }. Structural manifest error -> 422 via the #call rescue; per-tool
377
+ # failure stays isolated in `errors[]` (R4). Dynamic base_url: the egress guard
378
+ # + host_allowlist block destinations outside the allowlist at CALL time (R5).
379
+ def handle_import_tools(req)
380
+ gate = gateway_gate(req)
381
+ return gate if gate
382
+
383
+ command = Insika::Command.build(:import_tools, parse_raw_body(req), transport: :http)
384
+ json_response(200, dispatch_with_timeout(command))
385
+ end
386
+
387
+ # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
388
+ # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
389
+ # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
390
+ # at the composition root) and ingests them as data-tools (reuses :import_tools:
391
+ # upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
392
+ # + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
393
+ # failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
394
+ def handle_import_mcp_tools(req, name)
395
+ gate = gateway_gate(req)
396
+ return gate if gate
397
+
398
+ command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
399
+ json_response(200, dispatch_with_timeout(command))
400
+ end
401
+
402
+ # DELETE /v1/agents/:id — removes the agent (delete_agent). NotFoundError
403
+ # (missing) -> 404 via the #call rescue.
404
+ def handle_deprovision(req, id)
405
+ gate = gateway_gate(req)
406
+ return gate if gate
407
+
408
+ json_response(200, @provisioner.delete(id))
409
+ end
410
+
411
+ # GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
412
+ # (a client: it never reads a store) can tell "this case cannot run here" from
413
+ # "this case failed". Deliberately NOT the profile: the prompt,
414
+ # the model and the guardrail config are none of the caller's business. Just the
415
+ # two facts a case declares `requires` against.
416
+ #
417
+ # `tools` is the DECLARED allowlist, and `null` means the agent has an open one
418
+ # (every registered tool) — the client reads that as "cannot rule anything out"
419
+ # and runs the case rather than skipping it.
420
+ def handle_read_agent(id)
421
+ profile = @profiles.fetch(id)
422
+ raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
423
+
424
+ allow = profile.tools_allow
425
+ deny = Array(profile.tools_deny).map(&:to_s)
426
+ json_response(200, {
427
+ id: profile.id,
428
+ tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
429
+ capabilities: Array(profile.capabilities_declared).map(&:to_s)
430
+ })
431
+ end
432
+
433
+ # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
434
+ # shape is the platform's, so neither reads this header. Runs BEFORE the
435
+ # gateway gate: which version the caller asked for is a contract question,
436
+ # answerable regardless of whether the request is authorized. Absent header
437
+ # -> nil (current behaviour); an unknown value -> 400, not a silent fallback.
438
+ def version_gate(req)
439
+ version = req.get_header("HTTP_INSIKA_VERSION")
440
+ return nil if Coercion.blank?(version) || KNOWN_VERSIONS.include?(version)
441
+
442
+ error_response(400, Insika::ValidationError.new("unknown Insika-Version: #{version.inspect}"))
443
+ end
444
+
445
+ # Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
446
+ # ok (the handler proceeds). In multi_tenant mode the resolved principal
447
+ # is stashed on the request env: `tenant_principal?`/`req_tenant` read it
448
+ # back for the surface gate and the command stamping.
449
+ def gateway_gate(req)
450
+ result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
451
+ req.get_header("HTTP_AUTHORIZATION"))
452
+ case result
453
+ when :disabled then auth_error(503, "gateway disabled")
454
+ when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
455
+ else
456
+ req.set_header("insika.principal", result)
457
+ nil
458
+ end
459
+ end
460
+
461
+ # -> bool: is the requester a TENANT principal (multi_tenant mode only)?
462
+ # The surface gate and the session/task read gates consume it; an operator
463
+ # principal is NOT a tenant (it has the run of the deployment, exactly as
464
+ # in single_tenant mode).
465
+ def tenant_principal?(req)
466
+ p = req.get_header("insika.principal")
467
+ p && p[:role] == "tenant"
468
+ end
469
+
470
+ # The tenant the request operates AS: nil for an operator/classic request.
471
+ def req_tenant(req)
472
+ p = req.get_header("insika.principal")
473
+ p && p[:role] == "tenant" ? p[:tenant_id] : nil
474
+ end
475
+
476
+ # A tenant may reach ONLY its own runtime surfaces. Everything else
477
+ # (commands, provisioning, authoring, config) is the operator's. An
478
+ # unknown route is NOT here -> a tenant is refused (the surface exists —
479
+ # just not for them), not told it is missing.
480
+ TENANT_SURFACES = [
481
+ ["POST", ["v1", "sessions"]],
482
+ ["POST", ["v1", "messages"]],
483
+ ["POST", ["v1", "responses"]],
484
+ ["POST", ["v1", "workflows", nil]],
485
+ ["GET", ["v1", "workflows"]],
486
+ ["GET", ["v1", "sessions", nil]],
487
+ ["GET", ["v1", "tasks", nil]],
488
+ ["GET", ["v1", "events"]]
489
+ ].freeze
490
+ private_constant :TENANT_SURFACES
491
+
492
+ def tenant_surface?(method, segments)
493
+ TENANT_SURFACES.any? do |m, s|
494
+ m == method && s.zip(segments).all? { |pattern, got| pattern.nil? || pattern == got }
495
+ end
496
+ end
497
+
498
+ # Session id namespacing (WS1): a tenant's session lives under
499
+ # "<tenant>:<id>", so two tenants using the SAME chat id never share a
500
+ # session — the key itself is the isolation, not a convention. ":"
501
+ # (never "/") so the id stays one URL path segment; the same delimiter
502
+ # convention as the channels' "<channel>:<external_id>". Idempotent (a
503
+ # caller passing its own namespaced id back is not double-prefixed).
504
+ def scoped_session_id(tenant, id)
505
+ return id if tenant.nil? || id.nil? || id.to_s.empty?
506
+ return id if id.to_s.start_with?("#{tenant}:")
507
+
508
+ "#{tenant}:#{id}"
509
+ end
510
+
511
+ # POST /channels/:id/events — the Shape B inbound webhook.
512
+ # ACK FAST and never the reply: the platform (or the relay consumer) is holding
513
+ # a connection open with a retry timer on it, so this dispatches the turn and
514
+ # answers with its id. The answer itself leaves later, out of band, through the
515
+ # channel's own `deliver`.
516
+ #
517
+ # The channel does ALL the translating — auth, envelope, session correlation —
518
+ # and this handler stays what `server/` is allowed to be: a route that turns a
519
+ # request into a Command. Four answers, and each one is a different fact:
520
+ # 202 {task_id} a turn is running; its reply will be delivered
521
+ # 200 {task_id, duplicate} we already ran this event id
522
+ # 200 {task_id, merged} it joined a turn at the door
523
+ # 200 {task_id, steered} it was appended to a turn already running
524
+ # A consumer that treats the last three as 202 delivers the same answer twice.
525
+ def handle_channel_event(req, id)
526
+ channel = @channels.find(id)
527
+ return not_found if channel.nil?
528
+
529
+ gate = channel_gate(channel, req)
530
+ return gate if gate
531
+
532
+ parsed = channel.parse(req, body: parse_raw_body(req)) # ValidationError -> 422
533
+ session_id = channel.session_id_for(parsed[:external_id])
534
+ ensure_session(session_id, vars: session_vars(id, parsed))
535
+
536
+ payload = { agent: parsed[:agent], session_id: session_id,
537
+ message: parsed[:message], event_id: parsed[:event_id] }.compact
538
+ channel_ack(payload, transport: :"channel:#{id}")
539
+ end
540
+
541
+ # POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
542
+ # channel. The engine issues the id and the client never
543
+ # proposes one: an endpoint that created a session from a caller-supplied id
544
+ # would let anyone read someone else's conversation by guessing.
545
+ #
546
+ # Only a channel that mints answers here — the relay's id is the consumer's own
547
+ # key, so `/sessions` does not exist for it (404, the same parity every other
548
+ # optional surface has).
549
+ def handle_channel_session(req, id)
550
+ channel = @channels.find(id)
551
+ return not_found if channel.nil? || !channel.respond_to?(:mint_session_id)
552
+
553
+ gate = channel_gate(channel, req)
554
+ return cors(channel, req, gate) if gate
555
+
556
+ session_id = channel.mint_session_id
557
+ ensure_session(session_id, vars: { "channel" => id.to_s })
558
+ cors(channel, req, json_response(201, { session_id: session_id }))
559
+ end
560
+
561
+ # POST /channels/:id/messages — the Shape A turn: the reply comes back on THIS
562
+ # connection as SSE, so there is no outbox and nothing to deliver later. It is
563
+ # `handle_responses` with the hardcoded `Responses` module swapped for the
564
+ # looked-up channel, which is the whole point of naming the seam.
565
+ #
566
+ # The session must already exist AND belong to this channel. Both halves
567
+ # matter: create-on-write would reopen the enumeration hole `/sessions` closes,
568
+ # and skipping the ownership check would let a widget visitor stream a
569
+ # relay customer's conversation by pasting its id.
570
+ def handle_channel_message(req, id)
571
+ channel = @channels.find(id)
572
+ return not_found if channel.nil? || !channel.respond_to?(:frame_for)
573
+
574
+ gate = channel_gate(channel, req)
575
+ return cors(channel, req, gate) if gate
576
+
577
+ parsed = channel.parse(req, body: parse_raw_body(req))
578
+ return cors(channel, req, error_response(404, unknown_session)) unless channel_session?(id, parsed[:session_id])
579
+
580
+ payload = { agent: parsed[:agent], session_id: parsed[:session_id], message: parsed[:message] }
581
+ cors(channel, req, message_flow(payload, stream: true, transport: :"channel:#{id}",
582
+ serialize: channel.method(:frame_for)))
583
+ rescue Insika::ValidationError => e
584
+ # Answered here rather than through #call's rescue so the CORS headers ride
585
+ # along: without them the browser cannot read the 422 and the visitor sees a
586
+ # generic network failure instead of what was wrong.
587
+ cors(channel, req, error_response(422, e))
588
+ end
589
+
590
+ # GET /channels/:id/asset/:f — the channel's static file (the widget's JS).
591
+ # The name is a KEY of the channel's own closed map, never a path, so there is
592
+ # no traversal to find. Public and unauthenticated by nature: it is a
593
+ # `<script src>` on someone else's page.
594
+ # The cache policy is short and the ETag does the rest: the URL carries no
595
+ # version (the install snippet an adopter pasted has none), so a long max-age
596
+ # would strand every browser on the widget it already has, while a revalidation
597
+ # that answers 304 costs one empty round trip and ships an upgrade in minutes.
598
+ def handle_channel_asset(req, id, file)
599
+ channel = @channels.find(id)
600
+ return not_found if channel.nil? || !channel.respond_to?(:asset)
601
+
602
+ asset = channel.asset(file)
603
+ return not_found if asset.nil?
604
+
605
+ headers = { "content-type" => asset[:content_type],
606
+ "cache-control" => asset[:cache_control] || "no-cache",
607
+ "etag" => asset[:etag] }.compact
608
+ return [304, headers, []] if asset[:etag] && req.get_header("HTTP_IF_NONE_MATCH") == asset[:etag]
609
+
610
+ [200, headers, [asset[:body]]]
611
+ end
612
+
613
+ # OPTIONS /channels/:id/* — the CORS preflight. Answered BEFORE the channel's
614
+ # own check on purpose: a preflight carries no credentials (the browser strips
615
+ # them, by spec), so gating it would only mean the real request never happens.
616
+ # It grants nothing — an origin off the allowlist gets no headers back and the
617
+ # browser refuses the response itself.
618
+ def handle_channel_preflight(req, id)
619
+ channel = @channels.find(id)
620
+ return not_found if channel.nil?
621
+
622
+ cors(channel, req, [204, {}, []])
623
+ end
624
+
625
+ # Does this session exist AND belong to this channel? `vars["channel"]` is
626
+ # written when the session is minted.
627
+ def channel_session?(id, session_id)
628
+ session = session_id && @session_store.find(session_id)
629
+ return false if session.nil?
630
+
631
+ vars = session.vars || {}
632
+ (vars["channel"] || vars[:channel]).to_s == id.to_s
633
+ end
634
+
635
+ def unknown_session = Insika::NotFoundError.new("session not found")
636
+
637
+ # Merges the channel's CORS headers into a response it is about to return. A
638
+ # channel with no opinion (the relay: its consumer is a server, not a browser)
639
+ # changes nothing.
640
+ def cors(channel, req, response)
641
+ return response unless channel.respond_to?(:cors_headers)
642
+
643
+ headers = channel.cors_headers(req.get_header("HTTP_ORIGIN"))
644
+ return response if headers.nil? || headers.empty?
645
+
646
+ status, existing, body = response
647
+ [status, existing.merge(headers), body]
648
+ end
649
+
650
+ # The channel's OWN credential check. A channel returns a verdict, not a status
651
+ # code — HTTP is this file's vocabulary, not lib/'s — and a channel that never
652
+ # implements one is refused rather than defaulted open: an unauthenticated
653
+ # public inbound route with an LLM behind it is a money faucet.
654
+ def channel_gate(channel, req)
655
+ verdict = channel.respond_to?(:authenticate) ? channel.authenticate(req) : :disabled
656
+ case verdict
657
+ when :disabled then auth_error(503, "channel disabled")
658
+ when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
659
+ end
660
+ end
661
+
662
+ # Dispatch + ack, with NO subscription: nothing about this request waits for the
663
+ # turn. That is the difference between Shape B and every other surface here.
664
+ def channel_ack(payload, transport:)
665
+ result = @command_bus.dispatch(Insika::Command.build(:send_message, payload, transport: transport))
666
+ verdict = %i[duplicate merged steered].find { |k| result[k] }
667
+ return json_response(200, { task_id: result[:task_id], verdict => true }) if verdict
668
+
669
+ json_response(202, { task_id: result[:task_id] })
670
+ end
671
+
672
+ # `channel` + `external_id` on the session are how a later turn
673
+ # (and the outbox) know where a reply goes. The consumer's own `vars` ride along
674
+ # on first contact, but never over those two — a caller must not be able to
675
+ # rewrite its own conversation's address.
676
+ def session_vars(channel_id, parsed)
677
+ (parsed[:vars] || {}).merge("channel" => channel_id.to_s,
678
+ "external_id" => parsed[:external_id].to_s)
679
+ end
680
+
681
+ # Session correlated by an explicit id (`user`=chat.id on /v1/responses, the
682
+ # namespaced `<channel>:<external_id>` for a channel): creates if new, continues
683
+ # if it exists (multi-turn). Via Command (server/ does not write to a store).
684
+ # Benign race (two near-simultaneous turns creating) -> ArgumentError from the
685
+ # store, treated as "already exists". A TENANT's id is namespaced (WS1), so
686
+ # two tenants with the same chat id get two isolated sessions.
687
+ def ensure_session(id, vars: { channel: "responses" }, tenant: nil)
688
+ id = scoped_session_id(tenant, id)
689
+ return if @session_store.find(id)
690
+
691
+ @command_bus.dispatch(
692
+ Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
693
+ tenant: tenant)
694
+ )
695
+ rescue ArgumentError
696
+ nil
697
+ end
698
+
699
+ # GET /v1/sessions/:id — direct read (not a Command). A tenant may only
700
+ # read its OWN sessions: the ownership is the id namespace itself (its
701
+ # sessions live under "<tenant>:…"), anything else reads as a 404.
702
+ def handle_read_session(req, id)
703
+ tenant = req_tenant(req)
704
+ if tenant && !id.to_s.start_with?("#{tenant}:")
705
+ raise Insika::NotFoundError, "session not found: #{id}"
706
+ end
707
+
708
+ session = @session_store.find(id)
709
+ raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
710
+
711
+ json_response(200, { session: session.to_h })
712
+ end
713
+
714
+ # GET /v1/tasks/:id — direct read. This is where the consumer observes
715
+ # PolicyDenied/post-202 failures: the terminal state lives in the Task
716
+ # Store; nothing is lost if the client disconnected. A tenant may only
717
+ # read a task its own command stamped (the task record carries the
718
+ # command with its meta.tenant) — someone else's reads as a 404.
719
+ def handle_read_task(req, id)
720
+ task = @task_store.find(id)
721
+ raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
722
+
723
+ tenant = req_tenant(req)
724
+ if tenant && task_tenant(task) != tenant
725
+ raise Insika::NotFoundError, "task not found: #{id}"
726
+ end
727
+
728
+ body = { task: task_to_h(task) }
729
+ # pending approvals: this is where the consumer/operator sees
730
+ # what needs approval after an :approval_requested.
731
+ if @pending_action_store
732
+ body[:pending_actions] = @pending_action_store.open_for(id).map(&:to_h)
733
+ end
734
+ json_response(200, body)
735
+ end
736
+
737
+ # The tenant stamped on the task's persisted command (WS1): string or
738
+ # symbol keys, whichever the store round-trip produced.
739
+ def task_tenant(task)
740
+ command = task.respond_to?(:command) ? task.command : nil
741
+ return nil unless command.is_a?(Hash)
742
+
743
+ meta = command["meta"] || command[:meta] || {}
744
+ meta["tenant"] || meta[:tenant]
745
+ end
746
+
747
+ # GET /v1/events?task_id=&session_id= — here the filters ARE known.
748
+ # CONTINUOUS stream (post-crash reconnection route): does not close on a
749
+ # terminal event — ends on client disconnect or cap. A tenant's stream is
750
+ # scoped to its own events (fail-closed on the event's meta.tenant).
751
+ def handle_events(req)
752
+ subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
753
+ session_id: req.GET["session_id"],
754
+ tenant: req_tenant(req))
755
+ sse_response(subscription)
756
+ end
757
+
758
+ # POST /a2a — JSON-RPC 2.0: HTTP 200 ALWAYS (the error travels in the envelope,
759
+ # not in the status). Malformed JSON -> -32700 (A2A envelope, not the generic
760
+ # HTTP error of #call). The A2A::App never leaks an exception. Parse with STRING
761
+ # keys (the A2A wire is generic JSON — does NOT reuse `parse_body`, which
762
+ # symbolizes for Command payloads).
763
+ def handle_a2a(req)
764
+ raw = req.body&.read
765
+ body =
766
+ begin
767
+ raw.nil? || raw.empty? ? {} : JSON.parse(raw)
768
+ rescue StandardError
769
+ return json_response(200, A2A::Protocol.error(nil, A2A::Errors::PARSE_ERROR, "parse error"))
770
+ end
771
+ json_response(200, @a2a.rpc(body))
772
+ end
773
+
774
+ # --- Turn flow (SSE or aggregated) ------------------------------------
775
+
776
+ # Subscribe BEFORE dispatching: under Async the task fiber may
777
+ # run eagerly and emit :task_started before dispatch returns. The
778
+ # task_id only exists AFTER dispatch -> subscribe WITHOUT a filter and filter in the
779
+ # transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
780
+ # happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
781
+ # #call rescue (becomes an HTTP status).
782
+ def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
783
+ # WS1: a tenant's session_id is NAMESPACED before the command is built,
784
+ # so the turn lands on the tenant's OWN session even when another tenant
785
+ # uses the same chat id. The payload the caller sent is untouched.
786
+ if tenant
787
+ payload = payload.dup
788
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
789
+ end
790
+ command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
791
+ subscription = @event_stream.subscribe
792
+ result =
793
+ begin
794
+ @command_bus.dispatch(command)
795
+ rescue StandardError
796
+ subscription.close
797
+ raise
798
+ end
799
+
800
+ # the message joined another turn — one still waiting at the
801
+ # door (`merged`) or one already running (`steered`). Either way this call
802
+ # owns no reply; the one holding `task_id` does. Say exactly that and open no
803
+ # stream: a caller that delivered this response's (empty) output would
804
+ # duplicate the answer.
805
+ if result[:merged] || result[:steered]
806
+ subscription.close
807
+ verdict = result[:merged] ? :merged : :steered
808
+ return json_response(200, { task_id: result[:task_id], verdict => true })
809
+ end
810
+
811
+ task_id = result[:task_id]
812
+ # Bind the subscription to the task_id now that it exists: the cap now
813
+ # counts only events for THIS task and the overflow :error goes out with the
814
+ # right task_id. The already-enqueued events (eager fiber) belong to this task —
815
+ # none is lost.
816
+ subscription.bind(task_id: task_id)
817
+ filtered = TaskFilter.new(subscription, task_id)
818
+ stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
819
+ end
820
+
821
+ # Workflow trigger flow. Async by default (the honest workflow
822
+ # contract: fire the run, return the runId); ?stream=true streams the run's
823
+ # events like a turn. Same subscribe-before-dispatch discipline as
824
+ # message_flow so no eager event is lost when streaming. A synchronous handler
825
+ # error (bad input / unknown workflow) closes the subscription and propagates
826
+ # to #call (HTTP status).
827
+ def workflow_flow(payload, stream:, tenant: nil)
828
+ # WS1: same session-id namespacing as message_flow — the workflow's run
829
+ # session is the tenant's own.
830
+ if tenant
831
+ payload = payload.dup
832
+ payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
833
+ end
834
+ command = Insika::Command.build(:trigger_workflow, payload, transport: :http, tenant: tenant)
835
+
836
+ unless stream
837
+ result = dispatch_with_timeout(command)
838
+ return json_response(202, { run_id: result[:run_id] || result[:task_id], task_id: result[:task_id] })
839
+ end
840
+
841
+ subscription = @event_stream.subscribe
842
+ result =
843
+ begin
844
+ @command_bus.dispatch(command)
845
+ rescue StandardError
846
+ subscription.close
847
+ raise
848
+ end
849
+ task_id = result[:task_id]
850
+ subscription.bind(task_id: task_id)
851
+ sse_response(TaskFilter.new(subscription, task_id))
852
+ end
853
+
854
+ # stream=false: aggregates by iterating the filtered subscription in the
855
+ # request's own fiber. Accumulates :content deltas; responds at the
856
+ # terminal. The `error:` shape mirrors the :task_failed data (smallest
857
+ # coherent extension — the state is also in GET /v1/tasks/:id). Non-happy
858
+ # terminals (:task_cancelled, overflow :error) also become `error:` —
859
+ # a cancelled/truncated turn is NEVER reported as a 200 success.
860
+ def aggregate_response(subscription, task_id)
861
+ content = +""
862
+ events = []
863
+ error = nil
864
+
865
+ subscription.each do |event|
866
+ events << event.to_h
867
+ case event.type
868
+ when :content then content << event.data[:delta].to_s
869
+ when :task_failed
870
+ error = { class: event.data[:error], message: event.data[:message] }
871
+ # A8: the classification rides through when the executor wrapped
872
+ # the failure (ProviderError) — additive for every other terminal.
873
+ error = error.merge(event.data.slice(:kind, :retryable, :retry_after))
874
+ when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
875
+ when :error then error ||= { class: nil, message: event.data[:message] }
876
+ end
877
+ end
878
+
879
+ if error
880
+ json_response(200, { task_id: task_id, events: events, error: error })
881
+ else
882
+ json_response(200, { content: content, task_id: task_id, events: events })
883
+ end
884
+ end
885
+
886
+ def sse_response(subscription, serialize: nil)
887
+ [200, SSE_HEADERS.dup, SSEBody.new(subscription: subscription, heartbeat: @heartbeat, serialize: serialize)]
888
+ end
889
+
890
+ # --- Dispatch and serialization --------------------------------------
891
+
892
+ # Control Commands may exceed 10s -> 504. For
893
+ # turn Commands the dispatch returns immediately (the turn lives in the fiber) —
894
+ # the timeout is harmless. NEVER Timeout.timeout from the stdlib. With no current
895
+ # reactor (pure control test), dispatches directly.
896
+ def dispatch_with_timeout(command)
897
+ task = Async::Task.current?
898
+ return @command_bus.dispatch(command) if task.nil?
899
+
900
+ task.with_timeout(@sync_timeout) { @command_bus.dispatch(command) }
901
+ end
902
+
903
+ # Turn -> {task_id:} -> 202. Any other shape (control:
904
+ # Session/Task, which are Data) -> 200 with serialized to_h.
905
+ def command_response(result)
906
+ if turn_result?(result)
907
+ json_response(202, { task_id: result[:task_id] })
908
+ else
909
+ json_response(200, result.to_h)
910
+ end
911
+ end
912
+
913
+ # Turn = Hash with task_id PRESENT and non-nil. A control that
914
+ # returned a Hash without a useful task_id is not mistaken for a turn.
915
+ def turn_result?(result)
916
+ result.is_a?(Hash) && !result[:task_id].nil?
917
+ end
918
+
919
+ # Empty body or no content-type -> {} (transport validates only
920
+ # well-formed JSON; the payload belongs to the handler). Does NOT use req.params (it would
921
+ # consume the body as a form) — reads the raw body.
922
+ def parse_body(req)
923
+ raw = req.body&.read
924
+ return {} if raw.nil? || raw.empty?
925
+
926
+ JSON.parse(raw, symbolize_names: true)
927
+ end
928
+
929
+ # Like parse_body, but keeps STRING keys: for payloads with arbitrary
930
+ # DATA keys (a pack's file/skill names), which must not become
931
+ # symbols. Malformed JSON -> JSON::ParserError (#call maps it to 400).
932
+ def parse_raw_body(req)
933
+ raw = req.body&.read
934
+ return {} if raw.nil? || raw.empty?
935
+
936
+ JSON.parse(raw)
937
+ end
938
+
939
+ # Task#to_h is shallow (Data#to_h doesn't recurse): `executions` stays as an Array of
940
+ # Execution (Data), which JSON.generate would serialize as an opaque string
941
+ # (`"#<data ...>"`) — unreadable for the consumer observing failures via
942
+ # GET /v1/tasks/:id. Recurses the Executions serialization.
943
+ def task_to_h(task)
944
+ task.to_h.merge(executions: task.executions.map(&:to_h))
945
+ end
946
+
947
+ def json_response(status, body)
948
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
949
+ end
950
+
951
+ # Raw markdown (start.md / a public doc). charset is explicit so a coding agent
952
+ # fetching over HTTP decodes accents correctly.
953
+ def markdown_response(status, text)
954
+ [status, { "content-type" => "text/markdown; charset=utf-8" }, [text]]
955
+ end
956
+
957
+ def error_response(status, error)
958
+ json_response(status, { error: { class: error.class.name, message: error.message } })
959
+ end
960
+
961
+ # the 500 is the ONE status whose body carries the retry envelope —
962
+ # retryable/retry_after tell the client what to do, error_ref is what it
963
+ # quotes when the retry keeps failing. The SAME ref is logged here, or the
964
+ # field is decoration. 4xx stay bare: a client error is fixed by editing
965
+ # the request, not by waiting.
966
+ def internal_error_response(error)
967
+ ref = "err_#{SecureRandom.hex(8)}"
968
+ # Observability only (shutdown.rb's rule): a logger failure must never
969
+ # mask the 500 the client is owed.
970
+ begin
971
+ @logger&.puts("[server] #{ref} #{error.class}: #{error.message}\n" \
972
+ "#{Array(error.backtrace).first(5).join("\n")}")
973
+ rescue StandardError
974
+ nil
975
+ end
976
+ # B9/A8: a classified ProviderError quotes its own retry guidance;
977
+ # everything else keeps the blanket retry (the caller may have died mid
978
+ # request — a bounded wait is the safe default).
979
+ retryable = error.respond_to?(:retryable) && !error.retryable.nil? ? error.retryable : true
980
+ retry_after = error.respond_to?(:retry_after) && error.retry_after ? error.retry_after : RETRY_AFTER_SECONDS
981
+ body = { error: { class: error.class.name, message: error.message,
982
+ retryable: retryable, retry_after: retry_after, error_ref: ref } }
983
+ body[:error][:kind] = error.kind if error.respond_to?(:kind) && error.kind
984
+ json_response(500, body)
985
+ end
986
+
987
+ def not_found
988
+ [404, { "content-type" => "text/plain" }, ["not found"]]
989
+ end
990
+
991
+ # Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
992
+ # has already run (Boot only returns the app afterward), it's ready. Does NOT
993
+ # touch a store (health cannot fail on IO nor require auth).
994
+ def health = json_response(200, { status: "ok" })
995
+
996
+ # Thin Subscription decorator: discards
997
+ # events from OTHER tasks and CLOSES the subscription after forwarding the task's
998
+ # terminal event. Solves the subscribe-before-task_id gap without touching
999
+ # the Subscription's signature.
1000
+ class TaskFilter
1001
+ def initialize(subscription, task_id)
1002
+ @subscription = subscription
1003
+ @task_id = task_id
1004
+ end
1005
+
1006
+ def each
1007
+ @subscription.each do |event|
1008
+ next unless (event.meta || {})[:task_id] == @task_id
1009
+
1010
+ yield event
1011
+ break if TERMINAL_EVENTS.include?(event.type)
1012
+ end
1013
+ ensure
1014
+ @subscription.close
1015
+ end
1016
+
1017
+ def close = @subscription.close
1018
+ end
1019
+ private_constant :TaskFilter
1020
+ end
1021
+ end
1022
+ end