insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -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 +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -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 +362 -0
  22. data/docs/SKILLS.md +98 -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 +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,850 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+ require "async"
6
+ require_relative "sse_body"
7
+ require_relative "admin_auth" # Bearer checker shared by the gateway edge (fail-closed)
8
+ require_relative "a2a/app" # A2A edge adapter (pulls protocol/errors/message/projection/card)
9
+ require_relative "responses" # OpenAI Responses adapter (/v1/responses) — drop-in for the OpenClaw gateway
10
+
11
+ module Insika
12
+ module Server
13
+ # Rack app. Transports ONLY
14
+ # translate requests into Commands — the server holds no business
15
+ # logic. It parses JSON, builds `Command.build(...)`, dispatches on the
16
+ # CommandBus and projects the Event Stream to SSE. Reads are NOT Commands: they are
17
+ # direct reads from the stores.
18
+ #
19
+ # AUDITABLE constitutional rule: `server/` does not import the Executor,
20
+ # store WRITE methods, or RubyLLM. Requires: json, rack, async and the
21
+ # core types (Command/Event/errors) already loaded by the composition root.
22
+ class App
23
+ SSE_HEADERS = {
24
+ "content-type" => "text/event-stream",
25
+ "cache-control" => "no-cache",
26
+ "connection" => "keep-alive"
27
+ }.freeze
28
+
29
+ # Terminal events of a turn (close the task subscription in the
30
+ # transport). The overflow :error self-closes its own subscription at the
31
+ # EventStream (enqueues CLOSED), so it needs no entry here to end `each`.
32
+ TERMINAL_EVENTS = %i[task_completed task_failed task_cancelled].freeze
33
+ private_constant :TERMINAL_EVENTS
34
+
35
+ # RFC-0016 A5: the `/v1` contract, versioned by date. A caller PINS behaviour
36
+ # with `Insika-Version: YYYY-MM-DD` so a future breaking change does not move
37
+ # silently underneath it; absent header = today's (only) version. Only one
38
+ # entry exists so far — the day a second one is added, the routes that
39
+ # changed branch on this value instead of being served whichever behaviour
40
+ # happened to be current.
41
+ KNOWN_VERSIONS = ["2026-08-08"].freeze
42
+ private_constant :KNOWN_VERSIONS
43
+
44
+ # The operator control UI now lives in the Studio (§12 G5); server/ is a
45
+ # pure transport surface (/v1, /a2a). The constitutional rule holds: server/
46
+ # only READS stores and never imports the Executor, store writes, or RubyLLM.
47
+ def initialize(command_bus:, event_stream:, session_store:, task_store:,
48
+ config:, pending_action_store: nil, a2a: nil, provisioner: nil,
49
+ workflow_registry: nil, onboarding: nil, profiles: nil,
50
+ channels: nil)
51
+ @command_bus = command_bus
52
+ @event_stream = event_stream
53
+ @session_store = session_store
54
+ @task_store = task_store
55
+ @config = config
56
+ @pending_action_store = pending_action_store # read for GET /v1/tasks/:id
57
+ @a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
58
+ @provisioner = provisioner # PackImporter. nil = provisioning not exposed.
59
+ # Item 22 / §4.4: READ-ONLY registry, injected only where workflows are
60
+ # exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
61
+ # deployment does not expose workflows). Reading a catalog is a READ, like a
62
+ # store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
63
+ @workflow_registry = workflow_registry
64
+ # Item 20 / §5.6: LLM-first onboarding surface (start.md + models.json +
65
+ # docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
66
+ # is that the developer's coding agent can fetch it), and READ-ONLY, so the
67
+ # constitutional rule holds. nil = routes not exposed (parity — the production
68
+ # deployment opts in). Reading files/masked stores is a READ, like a store read.
69
+ @onboarding = onboarding
70
+ # RFC-0014 §3.2: READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
71
+ # what an agent has — the eval is a client and cannot read a store. Same
72
+ # constitutional footing as the workflow registry: reading a catalog is a
73
+ # READ. nil = the route 404s (parity).
74
+ @profiles = profiles
75
+ # RFC-0011 §4.4: ONE generic route family for every channel, opt-in by
76
+ # injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
77
+ # The channel does the translating; this class keeps doing only transport.
78
+ @channels = channels
79
+ @heartbeat = config.fetch(:heartbeat, 15)
80
+ @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
81
+ end
82
+
83
+ # Explicit routing, NO framework: ~10 routes in a `case`. A single
84
+ # `rescue` centralizes the error->status mapping. Only SYNCHRONOUS
85
+ # errors (before the fiber) become HTTP status; a task failure travels as
86
+ # an event on the stream and lands in GET /v1/tasks/:id.
87
+ def call(env)
88
+ req = Rack::Request.new(env)
89
+ route(req)
90
+ rescue JSON::ParserError => e
91
+ error_response(400, e) # malformed JSON, before any dispatch
92
+ rescue Insika::ValidationError => e
93
+ error_response(422, e)
94
+ rescue Insika::NotFoundError => e
95
+ error_response(404, e)
96
+ rescue Async::TimeoutError => e
97
+ error_response(504, e) # synchronous control request exceeded the ceiling
98
+ rescue StandardError => e
99
+ error_response(500, e)
100
+ end
101
+
102
+ private
103
+
104
+ def route(req)
105
+ # UTF-8, not the ASCII-8BIT Rack hands us. A path segment becomes a STORE KEY
106
+ # (`/v1/agents/:id`, `/v1/sessions/:id`), and the sqlite3 driver binds a
107
+ # BINARY string as a BLOB — which never matches a TEXT column. So every such
108
+ # read answered 404 on a durable deployment while passing every spec, because
109
+ # the in-memory store is a Ruby Hash and a binary string is `eql?` to its
110
+ # UTF-8 twin. Found by calling `GET /v1/agents/:id` against a real database.
111
+ segments = req.path_info.split("/").reject(&:empty?).map { |s| Coercion.utf8(s) }
112
+ if segments.first == "v1"
113
+ version_error = version_gate(req)
114
+ return version_error if version_error
115
+ end
116
+
117
+ gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
118
+ return gate if gate
119
+
120
+ case [req.request_method, segments]
121
+ in ["GET", ["up"]]
122
+ health # readiness/liveness (Railway/k8s) — no auth, no store access
123
+ in ["GET", ["start.md"]] if @onboarding
124
+ markdown_response(200, @onboarding.start_md(base_url: public_base(req)))
125
+ in ["GET", ["models.json"]] if @onboarding
126
+ json_response(200, @onboarding.models_json(base_url: public_base(req)))
127
+ in ["GET", ["docs"]] if @onboarding
128
+ json_response(200, { docs: @onboarding.docs_index(base_url: public_base(req)) })
129
+ in ["GET", ["docs", file]] if @onboarding && file.end_with?(".md")
130
+ handle_doc(file)
131
+ in ["POST", ["v1", "commands", type]]
132
+ handle_command(req, type)
133
+ in ["POST", ["v1", "sessions"]]
134
+ handle_create_session(req)
135
+ in ["POST", ["v1", "messages"]]
136
+ handle_send_message(req)
137
+ in ["GET", ["v1", "workflows"]] if @workflow_registry
138
+ handle_list_workflows
139
+ in ["POST", ["v1", "workflows", name]] if @workflow_registry
140
+ handle_trigger_workflow(req, name)
141
+ in ["POST", ["v1", "responses"]]
142
+ handle_responses(req)
143
+ in ["POST", ["v1", "tools", "manifest"]]
144
+ handle_import_tools(req)
145
+ in ["POST", ["v1", "mcp", name, "import"]]
146
+ handle_import_mcp_tools(req, name)
147
+ in ["POST", ["v1", "agents"]] if @provisioner
148
+ handle_provision(req)
149
+ in ["DELETE", ["v1", "agents", id]] if @provisioner
150
+ handle_deprovision(req, id)
151
+ in ["GET", ["v1", "agents", id]] if @profiles
152
+ handle_read_agent(id)
153
+ in ["GET", ["v1", "sessions", id]]
154
+ handle_read_session(id)
155
+ in ["GET", ["v1", "tasks", id]]
156
+ handle_read_task(id)
157
+ in ["GET", ["v1", "events"]]
158
+ handle_events(req)
159
+ in ["POST", ["channels", id, "events"]] if @channels
160
+ handle_channel_event(req, id)
161
+ in ["POST", ["channels", id, "sessions"]] if @channels
162
+ handle_channel_session(req, id)
163
+ in ["POST", ["channels", id, "messages"]] if @channels
164
+ handle_channel_message(req, id)
165
+ in ["GET", ["channels", id, "asset", file]] if @channels
166
+ handle_channel_asset(req, id, file)
167
+ in ["OPTIONS", ["channels", id, *]] if @channels
168
+ handle_channel_preflight(req, id)
169
+ in ["POST", ["a2a"]] if @a2a
170
+ handle_a2a(req)
171
+ in ["GET", [".well-known", "agent-card.json"]] if @a2a
172
+ json_response(200, @a2a.agent_card)
173
+ else
174
+ not_found # wrong method/route (or A2A not exposed -> @a2a nil)
175
+ end
176
+ end
177
+
178
+ # The ONLY routes that answer without the gateway Bearer. Everything else is gated
179
+ # in `route`, before the dispatch — an ALLOWLIST, because the previous shape (each
180
+ # handler calling `gateway_gate` itself) is a rule you have to remember: the generic
181
+ # `POST /v1/commands/:type` never called it, so every authoring Command
182
+ # (`write_agent_file`, `upsert_llm_provider`, `delete_agent`…) was reachable by
183
+ # anyone who knew the URL, as were the session/task/event reads. A route added
184
+ # tomorrow is closed by default; making it public is now a deliberate edit here.
185
+ #
186
+ # `/up` is the health probe (no store access). The onboarding surface is opt-in
187
+ # (INSIKA_ONBOARDING) and exists to be read by a coding agent before it has any
188
+ # credential — turning it on is the operator choosing to publish it.
189
+ PUBLIC_ROUTES = [
190
+ ["GET", ["up"]],
191
+ ["GET", ["start.md"]],
192
+ ["GET", ["models.json"]],
193
+ ["GET", ["docs"]],
194
+ ["GET", [".well-known", "agent-card.json"]] # A2A discovery: the card is the ad
195
+ ].freeze
196
+
197
+ def public_route?(method, segments)
198
+ return true if PUBLIC_ROUTES.include?([method, segments])
199
+ return true if channel_route?(method, segments)
200
+
201
+ method == "GET" && segments.length == 2 && segments.first == "docs"
202
+ end
203
+
204
+ # A channel route skips the GATEWAY bearer because the channel authenticates
205
+ # it ITSELF — with the platform's own scheme (a relay's shared secret, a Slack
206
+ # HMAC signature, the widget's origin allowlist plus its mandatory rate limit),
207
+ # which is the only credential the caller has. Requiring the gateway token here
208
+ # instead would mean handing every platform — and every anonymous browser — a
209
+ # second secret it has no way to send.
210
+ #
211
+ # This is NOT an ungated route family: every handler below calls `channel_gate`
212
+ # before parsing anything, and a channel that implements no `authenticate`, or
213
+ # whose credential is unconfigured, answers `:disabled` rather than open.
214
+ # ENUMERATED rather than prefix-matched, so a route added to this family
215
+ # tomorrow is gated by default and publishing it is a deliberate edit here.
216
+ def channel_route?(method, segments)
217
+ case [method, segments]
218
+ in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
219
+ in ["GET", ["channels", _, "asset", _]] then true
220
+ in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
221
+ else false
222
+ end
223
+ end
224
+
225
+ # Bearer-gate error (503 disabled / 401 unauthorized), shared by the
226
+ # gateway surfaces. JSON body, fail-closed.
227
+ def auth_error(status, message, extra_headers = {})
228
+ [status,
229
+ { "content-type" => "application/json" }.merge(extra_headers),
230
+ [JSON.generate(error: { class: "Insika::Error", message: message })]]
231
+ end
232
+
233
+ # POST /v1/commands/:type — generic: every new Command is born with a
234
+ # transport. The control vs turn distinction is BY THE SHAPE of the result (the
235
+ # transport knows no semantics).
236
+ def handle_command(req, type)
237
+ command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http)
238
+ command_response(dispatch_with_timeout(command))
239
+ end
240
+
241
+ # POST /v1/sessions — sugar for create_session; 201 {session}.
242
+ def handle_create_session(req)
243
+ body = parse_body(req)
244
+ command = Insika::Command.build(:create_session, { vars: body[:vars] || {} },
245
+ transport: :http)
246
+ session = dispatch_with_timeout(command)
247
+ json_response(201, { session: session.to_h })
248
+ end
249
+
250
+ # POST /v1/messages — sugar for send_message; ?stream missing/"true" -> SSE,
251
+ # "false" -> 200 JSON aggregated at the terminal event.
252
+ def handle_send_message(req)
253
+ stream = req.GET["stream"] != "false"
254
+ # RFC-0015 §5.5: only the aggregated-JSON form has room for the `merged`/`steered`
255
+ # verdict, so only it may join a message to another turn. Once the stream is open
256
+ # there is no way to tell the caller it does not own the reply.
257
+ message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json")
258
+ end
259
+
260
+ # GET /docs/:name.md — one public doc as raw markdown (item 20 / §5.6). The
261
+ # slug is a KEY of the onboarding allowlist, so no filesystem traversal is
262
+ # possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
263
+ def handle_doc(file)
264
+ markdown = @onboarding.doc(file.sub(/\.md\z/, ""))
265
+ return not_found if markdown.nil?
266
+
267
+ markdown_response(200, markdown)
268
+ end
269
+
270
+ # Public base url for the interpolated onboarding links. Prefers an explicit
271
+ # config[:public_url] (behind a proxy/TLS terminator the request scheme is the
272
+ # internal http), else the request's own base_url.
273
+ def public_base(req)
274
+ Insika::Coercion.presence(@config[:public_url]) || req.base_url
275
+ end
276
+
277
+ # GET /v1/workflows — discovery (item 22 / §4.4). Direct read of the
278
+ # registry catalog (name + description + the I/O schema contract). Not a
279
+ # Command; opt-in via the injected registry.
280
+ def handle_list_workflows
281
+ json_response(200, { workflows: @workflow_registry.catalog })
282
+ end
283
+
284
+ # POST /v1/workflows/:name — triggers a workflow RUN. The name comes from the
285
+ # ROUTE; agent/input/session_id from the body. Two shapes:
286
+ # · default -> 202 { run_id, task_id } immediately (async at-most-once
287
+ # run; observe via GET /v1/tasks/:run_id or GET /v1/events?task_id=:run_id).
288
+ # · ?stream=true -> SSE of the run's events (incl. :workflow_started /
289
+ # :workflow_completed), closing at the terminal event.
290
+ # A bad input (input_schema) is a synchronous 422 with no run (WorkflowSchemaError
291
+ # -> ValidationError in #call); an unknown workflow/agent -> 404/422.
292
+ def handle_trigger_workflow(req, name)
293
+ body = parse_body(req)
294
+ payload = { workflow: name, agent: body[:agent],
295
+ input: body[:input], session_id: body[:session_id] }.compact
296
+ workflow_flow(payload, stream: req.GET["stream"] == "true")
297
+ end
298
+
299
+ # POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
300
+ # gateway). Bearer via `config[:gateway_token]` (fail-closed). Translates
301
+ # the request -> :send_message and the turn's
302
+ # Event Stream -> OpenAI Responses SSE. Always streams (the consumer asks for SSE).
303
+ def handle_responses(req)
304
+ gate = gateway_gate(req)
305
+ return gate if gate
306
+
307
+ parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
308
+ ensure_session(parsed[:user])
309
+ payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
310
+ payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
311
+ message_flow(payload, stream: true, serialize: Responses.method(:frame_for))
312
+ end
313
+
314
+ # POST /v1/agents — provisions (upserts) an agent from a standardized
315
+ # PACK (Phase 6/D4/F7). Same Bearer as /v1/responses (gateway_token,
316
+ # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
317
+ # JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
318
+ # Raw body (string keys): the pack's file/skill names are data keys,
319
+ # not symbols.
320
+ def handle_provision(req)
321
+ gate = gateway_gate(req)
322
+ return gate if gate
323
+
324
+ pack = Insika::Pack.from_h(parse_raw_body(req))
325
+ json_response(200, @provisioner.import(pack)) # Validation/NotFound -> 422/404 in #call
326
+ end
327
+
328
+ # POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
329
+ # (Phase 7, Step B). Same Bearer as provisioning (gateway_token, fail-
330
+ # closed): it's an authoring/provisioning surface and resolves the
331
+ # deployment's secrets. RAW body (string keys): the JSON Schema property names and
332
+ # the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
333
+ # report }. Structural manifest error -> 422 via the #call rescue; per-tool
334
+ # failure stays isolated in `errors[]` (R4). Dynamic base_url: the egress guard
335
+ # + host_allowlist block destinations outside the allowlist at CALL time (R5).
336
+ def handle_import_tools(req)
337
+ gate = gateway_gate(req)
338
+ return gate if gate
339
+
340
+ command = Insika::Command.build(:import_tools, parse_raw_body(req), transport: :http)
341
+ json_response(200, dispatch_with_timeout(command))
342
+ end
343
+
344
+ # POST /v1/mcp/:name/import — LIVE MCP ingestion (Phase 7, Step E). Same
345
+ # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
346
+ # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
347
+ # at the composition root) and ingests them as data-tools (reuses :import_tools:
348
+ # upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
349
+ # + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
350
+ # failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
351
+ def handle_import_mcp_tools(req, name)
352
+ gate = gateway_gate(req)
353
+ return gate if gate
354
+
355
+ command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
356
+ json_response(200, dispatch_with_timeout(command))
357
+ end
358
+
359
+ # DELETE /v1/agents/:id — removes the agent (delete_agent). NotFoundError
360
+ # (missing) -> 404 via the #call rescue.
361
+ def handle_deprovision(req, id)
362
+ gate = gateway_gate(req)
363
+ return gate if gate
364
+
365
+ json_response(200, @provisioner.delete(id))
366
+ end
367
+
368
+ # GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
369
+ # (a client: it never reads a store) can tell "this case cannot run here" from
370
+ # "this case failed" (RFC-0014 §3.2). Deliberately NOT the profile: the prompt,
371
+ # the model and the guardrail config are none of the caller's business. Just the
372
+ # two facts a case declares `requires` against.
373
+ #
374
+ # `tools` is the DECLARED allowlist, and `null` means the agent has an open one
375
+ # (every registered tool) — the client reads that as "cannot rule anything out"
376
+ # and runs the case rather than skipping it.
377
+ def handle_read_agent(id)
378
+ profile = @profiles.fetch(id)
379
+ raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
380
+
381
+ allow = profile.tools_allow
382
+ deny = Array(profile.tools_deny).map(&:to_s)
383
+ json_response(200, {
384
+ id: profile.id,
385
+ tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
386
+ capabilities: Array(profile.capabilities_declared).map(&:to_s)
387
+ })
388
+ end
389
+
390
+ # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
391
+ # shape is the platform's, so neither reads this header. Runs BEFORE the
392
+ # gateway gate: which version the caller asked for is a contract question,
393
+ # answerable regardless of whether the request is authorized. Absent header
394
+ # -> nil (current behaviour); an unknown value -> 400, not a silent fallback.
395
+ def version_gate(req)
396
+ version = req.get_header("HTTP_INSIKA_VERSION")
397
+ return nil if Coercion.blank?(version) || KNOWN_VERSIONS.include?(version)
398
+
399
+ error_response(400, Insika::ValidationError.new("unknown Insika-Version: #{version.inspect}"))
400
+ end
401
+
402
+ # Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
403
+ # ok (the handler proceeds).
404
+ def gateway_gate(req)
405
+ case Insika::Server::AdminAuth.check(@config[:gateway_token], req.get_header("HTTP_AUTHORIZATION"))
406
+ when :disabled then auth_error(503, "gateway disabled")
407
+ when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
408
+ end
409
+ end
410
+
411
+ # POST /channels/:id/events — the Shape B inbound webhook (RFC-0011 §4.4).
412
+ # ACK FAST and never the reply: the platform (or the relay consumer) is holding
413
+ # a connection open with a retry timer on it, so this dispatches the turn and
414
+ # answers with its id. The answer itself leaves later, out of band, through the
415
+ # channel's own `deliver` (§6.5).
416
+ #
417
+ # The channel does ALL the translating — auth, envelope, session correlation —
418
+ # and this handler stays what `server/` is allowed to be: a route that turns a
419
+ # request into a Command. Four answers, and each one is a different fact:
420
+ # 202 {task_id} a turn is running; its reply will be delivered
421
+ # 200 {task_id, duplicate} we already ran this event id (§6.4)
422
+ # 200 {task_id, merged} it joined a turn at the door (RFC-0015 §5.5)
423
+ # 200 {task_id, steered} it was appended to a turn already running
424
+ # A consumer that treats the last three as 202 delivers the same answer twice.
425
+ def handle_channel_event(req, id)
426
+ channel = @channels.find(id)
427
+ return not_found if channel.nil?
428
+
429
+ gate = channel_gate(channel, req)
430
+ return gate if gate
431
+
432
+ parsed = channel.parse(req, body: parse_raw_body(req)) # ValidationError -> 422
433
+ session_id = channel.session_id_for(parsed[:external_id])
434
+ ensure_session(session_id, vars: session_vars(id, parsed))
435
+
436
+ payload = { agent: parsed[:agent], session_id: session_id,
437
+ message: parsed[:message], event_id: parsed[:event_id] }.compact
438
+ channel_ack(payload, transport: :"channel:#{id}")
439
+ end
440
+
441
+ # POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
442
+ # channel (RFC-0011 §4.3). The engine issues the id and the client never
443
+ # proposes one: an endpoint that created a session from a caller-supplied id
444
+ # would let anyone read someone else's conversation by guessing.
445
+ #
446
+ # Only a channel that mints answers here — the relay's id is the consumer's own
447
+ # key, so `/sessions` does not exist for it (404, the same parity every other
448
+ # optional surface has).
449
+ def handle_channel_session(req, id)
450
+ channel = @channels.find(id)
451
+ return not_found if channel.nil? || !channel.respond_to?(:mint_session_id)
452
+
453
+ gate = channel_gate(channel, req)
454
+ return cors(channel, req, gate) if gate
455
+
456
+ session_id = channel.mint_session_id
457
+ ensure_session(session_id, vars: { "channel" => id.to_s })
458
+ cors(channel, req, json_response(201, { session_id: session_id }))
459
+ end
460
+
461
+ # POST /channels/:id/messages — the Shape A turn: the reply comes back on THIS
462
+ # connection as SSE, so there is no outbox and nothing to deliver later. It is
463
+ # `handle_responses` with the hardcoded `Responses` module swapped for the
464
+ # looked-up channel, which is the whole point of naming the seam.
465
+ #
466
+ # The session must already exist AND belong to this channel. Both halves
467
+ # matter: create-on-write would reopen the enumeration hole `/sessions` closes,
468
+ # and skipping the ownership check would let a widget visitor stream a
469
+ # relay customer's conversation by pasting its id.
470
+ def handle_channel_message(req, id)
471
+ channel = @channels.find(id)
472
+ return not_found if channel.nil? || !channel.respond_to?(:frame_for)
473
+
474
+ gate = channel_gate(channel, req)
475
+ return cors(channel, req, gate) if gate
476
+
477
+ parsed = channel.parse(req, body: parse_raw_body(req))
478
+ return cors(channel, req, error_response(404, unknown_session)) unless channel_session?(id, parsed[:session_id])
479
+
480
+ payload = { agent: parsed[:agent], session_id: parsed[:session_id], message: parsed[:message] }
481
+ cors(channel, req, message_flow(payload, stream: true, transport: :"channel:#{id}",
482
+ serialize: channel.method(:frame_for)))
483
+ rescue Insika::ValidationError => e
484
+ # Answered here rather than through #call's rescue so the CORS headers ride
485
+ # along: without them the browser cannot read the 422 and the visitor sees a
486
+ # generic network failure instead of what was wrong.
487
+ cors(channel, req, error_response(422, e))
488
+ end
489
+
490
+ # GET /channels/:id/asset/:f — the channel's static file (the widget's JS).
491
+ # The name is a KEY of the channel's own closed map, never a path, so there is
492
+ # no traversal to find. Public and unauthenticated by nature: it is a
493
+ # `<script src>` on someone else's page.
494
+ # The cache policy is short and the ETag does the rest: the URL carries no
495
+ # version (the install snippet an adopter pasted has none), so a long max-age
496
+ # would strand every browser on the widget it already has, while a revalidation
497
+ # that answers 304 costs one empty round trip and ships an upgrade in minutes.
498
+ def handle_channel_asset(req, id, file)
499
+ channel = @channels.find(id)
500
+ return not_found if channel.nil? || !channel.respond_to?(:asset)
501
+
502
+ asset = channel.asset(file)
503
+ return not_found if asset.nil?
504
+
505
+ headers = { "content-type" => asset[:content_type],
506
+ "cache-control" => asset[:cache_control] || "no-cache",
507
+ "etag" => asset[:etag] }.compact
508
+ return [304, headers, []] if asset[:etag] && req.get_header("HTTP_IF_NONE_MATCH") == asset[:etag]
509
+
510
+ [200, headers, [asset[:body]]]
511
+ end
512
+
513
+ # OPTIONS /channels/:id/* — the CORS preflight. Answered BEFORE the channel's
514
+ # own check on purpose: a preflight carries no credentials (the browser strips
515
+ # them, by spec), so gating it would only mean the real request never happens.
516
+ # It grants nothing — an origin off the allowlist gets no headers back and the
517
+ # browser refuses the response itself.
518
+ def handle_channel_preflight(req, id)
519
+ channel = @channels.find(id)
520
+ return not_found if channel.nil?
521
+
522
+ cors(channel, req, [204, {}, []])
523
+ end
524
+
525
+ # Does this session exist AND belong to this channel? `vars["channel"]` is
526
+ # written when the session is minted (§4.3).
527
+ def channel_session?(id, session_id)
528
+ session = session_id && @session_store.find(session_id)
529
+ return false if session.nil?
530
+
531
+ vars = session.vars || {}
532
+ (vars["channel"] || vars[:channel]).to_s == id.to_s
533
+ end
534
+
535
+ def unknown_session = Insika::NotFoundError.new("session not found")
536
+
537
+ # Merges the channel's CORS headers into a response it is about to return. A
538
+ # channel with no opinion (the relay: its consumer is a server, not a browser)
539
+ # changes nothing.
540
+ def cors(channel, req, response)
541
+ return response unless channel.respond_to?(:cors_headers)
542
+
543
+ headers = channel.cors_headers(req.get_header("HTTP_ORIGIN"))
544
+ return response if headers.nil? || headers.empty?
545
+
546
+ status, existing, body = response
547
+ [status, existing.merge(headers), body]
548
+ end
549
+
550
+ # The channel's OWN credential check. A channel returns a verdict, not a status
551
+ # code — HTTP is this file's vocabulary, not lib/'s — and a channel that never
552
+ # implements one is refused rather than defaulted open: an unauthenticated
553
+ # public inbound route with an LLM behind it is a money faucet.
554
+ def channel_gate(channel, req)
555
+ verdict = channel.respond_to?(:authenticate) ? channel.authenticate(req) : :disabled
556
+ case verdict
557
+ when :disabled then auth_error(503, "channel disabled")
558
+ when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
559
+ end
560
+ end
561
+
562
+ # Dispatch + ack, with NO subscription: nothing about this request waits for the
563
+ # turn. That is the difference between Shape B and every other surface here.
564
+ def channel_ack(payload, transport:)
565
+ result = @command_bus.dispatch(Insika::Command.build(:send_message, payload, transport: transport))
566
+ verdict = %i[duplicate merged steered].find { |k| result[k] }
567
+ return json_response(200, { task_id: result[:task_id], verdict => true }) if verdict
568
+
569
+ json_response(202, { task_id: result[:task_id] })
570
+ end
571
+
572
+ # RFC-0011 §4.3: `channel` + `external_id` on the session are how a later turn
573
+ # (and the outbox) know where a reply goes. The consumer's own `vars` ride along
574
+ # on first contact, but never over those two — a caller must not be able to
575
+ # rewrite its own conversation's address.
576
+ def session_vars(channel_id, parsed)
577
+ (parsed[:vars] || {}).merge("channel" => channel_id.to_s,
578
+ "external_id" => parsed[:external_id].to_s)
579
+ end
580
+
581
+ # Session correlated by an explicit id (`user`=chat.id on /v1/responses, the
582
+ # namespaced `<channel>:<external_id>` for a channel): creates if new, continues
583
+ # if it exists (multi-turn). Via Command (server/ does not write to a store).
584
+ # Benign race (two near-simultaneous turns creating) -> ArgumentError from the
585
+ # store, treated as "already exists".
586
+ def ensure_session(id, vars: { channel: "responses" })
587
+ return if @session_store.find(id)
588
+
589
+ @command_bus.dispatch(
590
+ Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http)
591
+ )
592
+ rescue ArgumentError
593
+ nil
594
+ end
595
+
596
+ # GET /v1/sessions/:id — direct read (not a Command).
597
+ def handle_read_session(id)
598
+ session = @session_store.find(id)
599
+ raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
600
+
601
+ json_response(200, { session: session.to_h })
602
+ end
603
+
604
+ # GET /v1/tasks/:id — direct read. This is where the consumer observes
605
+ # PolicyDenied/post-202 failures: the terminal state lives in the Task
606
+ # Store; nothing is lost if the client disconnected.
607
+ def handle_read_task(id)
608
+ task = @task_store.find(id)
609
+ raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
610
+
611
+ body = { task: task_to_h(task) }
612
+ # pending approvals: this is where the consumer/operator sees
613
+ # what needs approval after an :approval_requested.
614
+ if @pending_action_store
615
+ body[:pending_actions] = @pending_action_store.open_for(id).map(&:to_h)
616
+ end
617
+ json_response(200, body)
618
+ end
619
+
620
+ # GET /v1/events?task_id=&session_id= — here the filters ARE known.
621
+ # CONTINUOUS stream (post-crash reconnection route): does not close on a
622
+ # terminal event — ends on client disconnect or cap.
623
+ def handle_events(req)
624
+ subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
625
+ session_id: req.GET["session_id"])
626
+ sse_response(subscription)
627
+ end
628
+
629
+ # POST /a2a — JSON-RPC 2.0: HTTP 200 ALWAYS (the error travels in the envelope,
630
+ # not in the status). Malformed JSON -> -32700 (A2A envelope, not the generic
631
+ # HTTP error of #call). The A2A::App never leaks an exception. Parse with STRING
632
+ # keys (the A2A wire is generic JSON — does NOT reuse `parse_body`, which
633
+ # symbolizes for Command payloads).
634
+ def handle_a2a(req)
635
+ raw = req.body&.read
636
+ body =
637
+ begin
638
+ raw.nil? || raw.empty? ? {} : JSON.parse(raw)
639
+ rescue StandardError
640
+ return json_response(200, A2A::Protocol.error(nil, A2A::Errors::PARSE_ERROR, "parse error"))
641
+ end
642
+ json_response(200, @a2a.rpc(body))
643
+ end
644
+
645
+ # --- Turn flow (SSE or aggregated) ------------------------------------
646
+
647
+ # Subscribe BEFORE dispatching: under Async the task fiber may
648
+ # run eagerly and emit :task_started before dispatch returns. The
649
+ # task_id only exists AFTER dispatch -> subscribe WITHOUT a filter and filter in the
650
+ # transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
651
+ # happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
652
+ # #call rescue (becomes an HTTP status).
653
+ def message_flow(payload, stream:, serialize: nil, transport: :http)
654
+ command = Insika::Command.build(:send_message, payload, transport: transport)
655
+ subscription = @event_stream.subscribe
656
+ result =
657
+ begin
658
+ @command_bus.dispatch(command)
659
+ rescue StandardError
660
+ subscription.close
661
+ raise
662
+ end
663
+
664
+ # RFC-0015 §5.5: the message joined another turn — one still waiting at the
665
+ # door (`merged`) or one already running (`steered`). Either way this call
666
+ # owns no reply; the one holding `task_id` does. Say exactly that and open no
667
+ # stream: a caller that delivered this response's (empty) output would
668
+ # duplicate the answer.
669
+ if result[:merged] || result[:steered]
670
+ subscription.close
671
+ verdict = result[:merged] ? :merged : :steered
672
+ return json_response(200, { task_id: result[:task_id], verdict => true })
673
+ end
674
+
675
+ task_id = result[:task_id]
676
+ # Bind the subscription to the task_id now that it exists: the cap now
677
+ # counts only events for THIS task and the overflow :error goes out with the
678
+ # right task_id. The already-enqueued events (eager fiber) belong to this task —
679
+ # none is lost.
680
+ subscription.bind(task_id: task_id)
681
+ filtered = TaskFilter.new(subscription, task_id)
682
+ stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
683
+ end
684
+
685
+ # Workflow trigger flow (item 22). Async by default (the honest workflow
686
+ # contract: fire the run, return the runId); ?stream=true streams the run's
687
+ # events like a turn. Same subscribe-before-dispatch discipline as
688
+ # message_flow so no eager event is lost when streaming. A synchronous handler
689
+ # error (bad input / unknown workflow) closes the subscription and propagates
690
+ # to #call (HTTP status).
691
+ def workflow_flow(payload, stream:)
692
+ command = Insika::Command.build(:trigger_workflow, payload, transport: :http)
693
+
694
+ unless stream
695
+ result = dispatch_with_timeout(command)
696
+ return json_response(202, { run_id: result[:run_id] || result[:task_id], task_id: result[:task_id] })
697
+ end
698
+
699
+ subscription = @event_stream.subscribe
700
+ result =
701
+ begin
702
+ @command_bus.dispatch(command)
703
+ rescue StandardError
704
+ subscription.close
705
+ raise
706
+ end
707
+ task_id = result[:task_id]
708
+ subscription.bind(task_id: task_id)
709
+ sse_response(TaskFilter.new(subscription, task_id))
710
+ end
711
+
712
+ # stream=false: aggregates by iterating the filtered subscription in the
713
+ # request's own fiber. Accumulates :content deltas; responds at the
714
+ # terminal. The `error:` shape mirrors the :task_failed data (smallest
715
+ # coherent extension — the state is also in GET /v1/tasks/:id). Non-happy
716
+ # terminals (:task_cancelled, overflow :error) also become `error:` —
717
+ # a cancelled/truncated turn is NEVER reported as a 200 success.
718
+ def aggregate_response(subscription, task_id)
719
+ content = +""
720
+ events = []
721
+ error = nil
722
+
723
+ subscription.each do |event|
724
+ events << event.to_h
725
+ case event.type
726
+ when :content then content << event.data[:delta].to_s
727
+ when :task_failed then error = { class: event.data[:error], message: event.data[:message] }
728
+ when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
729
+ when :error then error ||= { class: nil, message: event.data[:message] }
730
+ end
731
+ end
732
+
733
+ if error
734
+ json_response(200, { task_id: task_id, events: events, error: error })
735
+ else
736
+ json_response(200, { content: content, task_id: task_id, events: events })
737
+ end
738
+ end
739
+
740
+ def sse_response(subscription, serialize: nil)
741
+ [200, SSE_HEADERS.dup, SSEBody.new(subscription: subscription, heartbeat: @heartbeat, serialize: serialize)]
742
+ end
743
+
744
+ # --- Dispatch and serialization --------------------------------------
745
+
746
+ # Control Commands may exceed 10s -> 504. For
747
+ # turn Commands the dispatch returns immediately (the turn lives in the fiber) —
748
+ # the timeout is harmless. NEVER Timeout.timeout from the stdlib. With no current
749
+ # reactor (pure control test), dispatches directly.
750
+ def dispatch_with_timeout(command)
751
+ task = Async::Task.current?
752
+ return @command_bus.dispatch(command) if task.nil?
753
+
754
+ task.with_timeout(@sync_timeout) { @command_bus.dispatch(command) }
755
+ end
756
+
757
+ # Turn -> {task_id:} -> 202. Any other shape (control:
758
+ # Session/Task, which are Data) -> 200 with serialized to_h.
759
+ def command_response(result)
760
+ if turn_result?(result)
761
+ json_response(202, { task_id: result[:task_id] })
762
+ else
763
+ json_response(200, result.to_h)
764
+ end
765
+ end
766
+
767
+ # Turn = Hash with task_id PRESENT and non-nil. A control that
768
+ # returned a Hash without a useful task_id is not mistaken for a turn.
769
+ def turn_result?(result)
770
+ result.is_a?(Hash) && !result[:task_id].nil?
771
+ end
772
+
773
+ # Empty body or no content-type -> {} (transport validates only
774
+ # well-formed JSON; the payload belongs to the handler). Does NOT use req.params (it would
775
+ # consume the body as a form) — reads the raw body.
776
+ def parse_body(req)
777
+ raw = req.body&.read
778
+ return {} if raw.nil? || raw.empty?
779
+
780
+ JSON.parse(raw, symbolize_names: true)
781
+ end
782
+
783
+ # Like parse_body, but keeps STRING keys: for payloads with arbitrary
784
+ # DATA keys (a pack's file/skill names), which must not become
785
+ # symbols. Malformed JSON -> JSON::ParserError (#call maps it to 400).
786
+ def parse_raw_body(req)
787
+ raw = req.body&.read
788
+ return {} if raw.nil? || raw.empty?
789
+
790
+ JSON.parse(raw)
791
+ end
792
+
793
+ # Task#to_h is shallow (Data#to_h doesn't recurse): `executions` stays as an Array of
794
+ # Execution (Data), which JSON.generate would serialize as an opaque string
795
+ # (`"#<data ...>"`) — unreadable for the consumer observing failures via
796
+ # GET /v1/tasks/:id. Recurses the Executions serialization.
797
+ def task_to_h(task)
798
+ task.to_h.merge(executions: task.executions.map(&:to_h))
799
+ end
800
+
801
+ def json_response(status, body)
802
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
803
+ end
804
+
805
+ # Raw markdown (start.md / a public doc). charset is explicit so a coding agent
806
+ # fetching over HTTP decodes accents correctly.
807
+ def markdown_response(status, text)
808
+ [status, { "content-type" => "text/markdown; charset=utf-8" }, [text]]
809
+ end
810
+
811
+ def error_response(status, error)
812
+ json_response(status, { error: { class: error.class.name, message: error.message } })
813
+ end
814
+
815
+ def not_found
816
+ [404, { "content-type" => "text/plain" }, ["not found"]]
817
+ end
818
+
819
+ # Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
820
+ # has already run (Boot only returns the app afterward — doc 07 §4), it's ready. Does NOT
821
+ # touch a store (health cannot fail on IO nor require auth).
822
+ def health = json_response(200, { status: "ok" })
823
+
824
+ # Thin Subscription decorator: discards
825
+ # events from OTHER tasks and CLOSES the subscription after forwarding the task's
826
+ # terminal event. Solves the subscribe-before-task_id gap without touching
827
+ # the Subscription's signature.
828
+ class TaskFilter
829
+ def initialize(subscription, task_id)
830
+ @subscription = subscription
831
+ @task_id = task_id
832
+ end
833
+
834
+ def each
835
+ @subscription.each do |event|
836
+ next unless (event.meta || {})[:task_id] == @task_id
837
+
838
+ yield event
839
+ break if TERMINAL_EVENTS.include?(event.type)
840
+ end
841
+ ensure
842
+ @subscription.close
843
+ end
844
+
845
+ def close = @subscription.close
846
+ end
847
+ private_constant :TaskFilter
848
+ end
849
+ end
850
+ end