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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # RFC-0015 §5.2 — WHERE a message that arrived mid-run is allowed to enter the
5
+ # conversation.
6
+ #
7
+ # A customer who corrects themselves while the agent is calling tools ("1234567",
8
+ # three seconds after "queria saber do pedido") should have that land before the
9
+ # model's next reasoning step, not after the whole run. RubyLLM runs the entire tool
10
+ # loop inside `chat.ask`, so the only place to append is from inside its callbacks —
11
+ # which is enough, because they are public and additive:
12
+ #
13
+ # complete_once
14
+ # ├─ provider_completion → assistant message announcing N tool_calls
15
+ # ├─ after_message(assistant) ← N is read here
16
+ # └─ handle_tool_calls
17
+ # ├─ add_tool_result_message ×N
18
+ # │ └─ after_message(tool) ← counted; the Nth is THE BOUNDARY
19
+ # └─ halt_result || complete ← the next model step sees what we appended
20
+ #
21
+ # Counting to N is not an optimization, it is the correctness condition. A `user`
22
+ # message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
23
+ # results of a batch must sit together) and merely tolerated by OpenAI.
24
+ #
25
+ # Two invariants this object exists to keep:
26
+ #
27
+ # · **Tail-append only.** Nothing already sent to the provider is edited, reordered
28
+ # or removed. That is what keeps the prompt cache valid, and a cache miss on a
29
+ # ~48k-token identity is a real cost, not a theoretical one.
30
+ # · **A halted batch injects nothing.** With `halt_when` there is no next model step
31
+ # (`handle_tool_calls` returns the Halt), so an appended message would sit in the
32
+ # transcript unanswered forever. The messages stay in the mailbox and the Executor
33
+ # releases them as a follow-up turn.
34
+ class SteerInjector
35
+ # chat: the turn's RubyLLM::Chat (already assembled).
36
+ # actor: the turn's TaskActor — the mailbox the steered messages arrive in.
37
+ # policy: the turn's QueuePolicy (`frame` decides how the text is worded).
38
+ # emit: ->(type, data) — the Executor's emitter, already bound to the task.
39
+ def initialize(chat:, actor:, policy:, emit:)
40
+ @chat = chat
41
+ @actor = actor
42
+ @policy = policy
43
+ @emit = emit
44
+ @expected = nil # tool calls announced by the batch in flight (nil = not in one)
45
+ @seen = 0
46
+ @halted = false
47
+ @injected = 0
48
+ end
49
+
50
+ # How many messages this run absorbed (read by specs and by the turn's event).
51
+ attr_reader :injected
52
+
53
+ # RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
54
+ # is still recognizable. By the time it becomes a `role: tool` message its content
55
+ # is the payload, indistinguishable from an ordinary result.
56
+ def tool_result(result)
57
+ @halted = true if halt?(result)
58
+ end
59
+
60
+ # RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
61
+ # the Nth tool result CLOSES it, and that is the one boundary where appending is
62
+ # valid.
63
+ def message_ended(message)
64
+ role = field(message, :role).to_s
65
+ return open_batch(message) if role == "assistant"
66
+ return unless role == "tool" && @expected
67
+
68
+ @seen += 1
69
+ inject! if @seen >= @expected
70
+ end
71
+
72
+ private
73
+
74
+ def open_batch(message)
75
+ calls = field(message, :tool_calls)
76
+ size = calls.respond_to?(:size) ? calls.size : 0
77
+ # A message with no tool call is the model talking, not a batch: leave any
78
+ # pending message where it is (the turn is about to end, and the Executor
79
+ # releases it as a follow-up turn rather than answering it half-way).
80
+ return @expected = nil if size.zero?
81
+
82
+ @expected = size
83
+ @seen = 0
84
+ @halted = false
85
+ end
86
+
87
+ def inject!
88
+ @expected = nil
89
+ return if @halted # nothing will read it: leave it in the mailbox
90
+
91
+ texts = @actor.take_user_messages!
92
+ return if texts.empty?
93
+
94
+ texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
95
+ @injected += texts.size
96
+ # Counts only, never content — the text is already in the transcript, which is the
97
+ # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
98
+ @emit.call(:turn_steered, { count: texts.size, total: @injected })
99
+ end
100
+
101
+ def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
102
+
103
+ def field(message, name)
104
+ return message.public_send(name) if message.respond_to?(name)
105
+ return message[name] || message[name.to_s] if message.respond_to?(:[])
106
+
107
+ nil
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Minimal persistence contract.
5
+ # Namespace-scoped KV, transactional when the backend supports it.
6
+ # Every implementation passes the SAME contract suite
7
+ # (lib/insika/testing/store_contract.rb — requirable from outside the repo,
8
+ # RFC-0018 A4). Values must be JSON-serializable.
9
+ #
10
+ # scope: String — separates domains/tenants (e.g. "sessions", "tasks:tenant_x")
11
+ # key: Hierarchical String (e.g. "task:123", "checkpoint:123:turn:4")
12
+ #
13
+ # Contract rules (verified by the suite):
14
+ # - get on a nonexistent key -> nil (never an exception)
15
+ # - set overwrites silently (last-write-wins)
16
+ # - round-trip preserves JSON types; Symbols become Strings (the domain
17
+ # normalizes at the boundary)
18
+ # - list(scope) returns only keys of the scope, ordered lexicographically;
19
+ # prefix filters by start_with?
20
+ # - a nested transaction reuses the outer transaction (no SAVEPOINT)
21
+ # - a serialization failure on write -> Insika::StoreError (fail-fast)
22
+ #
23
+ # Backends `include Store` and override the five methods; any forgotten
24
+ # method raises NotImplementedError (fail-fast, better than a distant
25
+ # NoMethodError).
26
+ module Store
27
+ # -> Object | nil (deserialized)
28
+ def get(scope, key)
29
+ raise NotImplementedError, "#{self.class}#get"
30
+ end
31
+
32
+ # -> value (the SAME object passed in, not the round-trip)
33
+ def set(scope, key, value)
34
+ raise NotImplementedError, "#{self.class}#set"
35
+ end
36
+
37
+ # -> true | false (did it exist?)
38
+ def delete(scope, key)
39
+ raise NotImplementedError, "#{self.class}#delete"
40
+ end
41
+
42
+ # -> [String] keys ordered lexicographically
43
+ def list(scope, prefix = nil)
44
+ raise NotImplementedError, "#{self.class}#list"
45
+ end
46
+
47
+ # -> the block's result; atomic if the backend supports it
48
+ def transaction(&blk)
49
+ raise NotImplementedError, "#{self.class}#transaction"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Stores
7
+ # In-memory backend for dev/test.
8
+ # Serializes JSON even in memory: exact parity of type semantics
9
+ # with SQLite — the contract suite is honest.
10
+ # No lock: cooperative fibers do not preempt in the middle of a Hash
11
+ # operation.
12
+ class Memory
13
+ include Store
14
+
15
+ def initialize
16
+ @data = new_store
17
+ @tx_depth = 0
18
+ @snapshot = nil
19
+ end
20
+
21
+ def get(scope, key)
22
+ raw = @data[scope][key]
23
+ return nil if raw.nil?
24
+
25
+ JSON.parse(raw)
26
+ end
27
+
28
+ def set(scope, key, value)
29
+ @data[scope][key] = serialize(value)
30
+ value
31
+ end
32
+
33
+ def delete(scope, key)
34
+ !@data[scope].delete(key).nil?
35
+ end
36
+
37
+ def list(scope, prefix = nil)
38
+ keys = @data[scope].keys.sort
39
+ prefix ? keys.select { |k| k.start_with?(prefix) } : keys
40
+ end
41
+
42
+ # Snapshot at the start of the outermost transaction; an exception at any
43
+ # level -> restore the snapshot and re-propagate (a REAL rollback).
44
+ # A nested one reuses the outer (no SAVEPOINT).
45
+ def transaction
46
+ if @tx_depth.positive?
47
+ @tx_depth += 1
48
+ begin
49
+ return yield
50
+ ensure
51
+ @tx_depth -= 1
52
+ end
53
+ end
54
+
55
+ @snapshot = deep_snapshot
56
+ @tx_depth = 1
57
+ begin
58
+ yield
59
+ rescue StandardError
60
+ restore_snapshot
61
+ raise
62
+ ensure
63
+ @tx_depth = 0
64
+ @snapshot = nil
65
+ end
66
+ end
67
+
68
+ # JSON model types + Symbol (coerced to String on write).
69
+ # Any other type is "garbage" and must be rejected.
70
+ JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
71
+ String, Symbol].freeze
72
+ private_constant :JSONABLE
73
+
74
+ private
75
+
76
+ def new_store
77
+ Hash.new { |h, scope| h[scope] = {} }
78
+ end
79
+
80
+ # Enforces the contract's type model at the boundary:
81
+ # Symbol/symbol-key become String; a type outside the JSON model ->
82
+ # StoreError on WRITE (fail-fast; never writes garbage).
83
+ #
84
+ # Does not use `JSON.generate(strict: true)`: under json 2.7.1 (the pinned
85
+ # version) `strict` rejects Symbol, which would violate the Symbol coercion.
86
+ # The explicit validation is independent of the json version and gives the
87
+ # SAME semantics as SQLite.
88
+ # The transaction block's exception (from the caller) propagates without
89
+ # wrapping; only a backend error becomes StoreError.
90
+ def serialize(value)
91
+ ensure_jsonable!(value)
92
+ JSON.generate(value)
93
+ rescue JSON::GeneratorError => e
94
+ raise Insika::StoreError, "value not serializable: #{e.message}"
95
+ end
96
+
97
+ def ensure_jsonable!(value)
98
+ case value
99
+ when *JSONABLE then nil
100
+ when Array then value.each { |v| ensure_jsonable!(v) }
101
+ when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
102
+ else
103
+ raise Insika::StoreError,
104
+ "value not serializable: #{value.class} not allowed in JSON"
105
+ end
106
+ end
107
+
108
+ # Deep dup: the values are already JSON strings (immutable in practice),
109
+ # so a per-scope dup is enough — there is no nested mutable structure.
110
+ def deep_snapshot
111
+ @data.each_with_object({}) { |(scope, kv), acc| acc[scope] = kv.dup }
112
+ end
113
+
114
+ # Recreates the Hash with the default proc (otherwise @data[scope] on a
115
+ # new scope after rollback would raise) and restores only the snapshot's
116
+ # scopes — scopes created inside the transaction disappear.
117
+ def restore_snapshot
118
+ @data = new_store
119
+ @snapshot.each { |scope, kv| @data[scope] = kv }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "async/semaphore"
6
+
7
+ module Insika
8
+ module Stores
9
+ # SQLite backend — the production default.
10
+ # A single kv table; the domain lives in the scopes. One handle per process,
11
+ # writes in a transaction serialized by an Async::Semaphore.
12
+ class SQLite
13
+ include Store
14
+
15
+ DDL = <<~SQL
16
+ CREATE TABLE IF NOT EXISTS kv (
17
+ scope TEXT NOT NULL,
18
+ key TEXT NOT NULL,
19
+ value TEXT NOT NULL,
20
+ updated_at TEXT NOT NULL,
21
+ PRIMARY KEY (scope, key)
22
+ ) WITHOUT ROWID;
23
+ SQL
24
+
25
+ # JSON model types + Symbol (coerced to String on write).
26
+ # Any other type is "garbage" and must be rejected. Identical to
27
+ # Stores::Memory — both backends share the SAME type model
28
+ # (the contract suite is honest).
29
+ JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
30
+ String, Symbol].freeze
31
+ private_constant :JSONABLE
32
+
33
+ # lazy require: the core installs without the sqlite3 gem
34
+ # when only Memory is used.
35
+ def initialize(path:, serializer: JSON)
36
+ require "sqlite3"
37
+
38
+ @serializer = serializer
39
+ @db = SQLite3::Database.new(path)
40
+ @write_semaphore = Async::Semaphore.new(1)
41
+ @tx_owner = nil
42
+
43
+ # Multi-process boot (N Falcon workers opening the SAME file at the
44
+ # same time): `PRAGMA journal_mode = WAL` on a new file needs an
45
+ # EXCLUSIVE lock and may return SQLITE_BUSY right then — the busy
46
+ # timeout alone does NOT cover the journal-mode switch. Hence: timeout
47
+ # FIRST (covers the DDL and the hot path) + retry with backoff around
48
+ # the initialization (covers the WAL-switch race). Idempotent:
49
+ # reopening already-in-WAL is a no-op.
50
+ #
51
+ # `busy_handler_timeout=`, NOT `busy_timeout=`: the C-level handler
52
+ # sleeps holding the GVL, so a worker waiting on another PROCESS's
53
+ # write lock would stall every fiber it is running for up to the full
54
+ # timeout (measured: a waiter blocks the winner's own commit). The
55
+ # Ruby-level handler sleeps in Ruby — the scheduler keeps the rest of
56
+ # the worker breathing while this handle waits its turn.
57
+ @db.busy_handler_timeout = 5_000
58
+ with_busy_retry do
59
+ @db.execute("PRAGMA journal_mode = WAL")
60
+ @db.execute("PRAGMA synchronous = NORMAL")
61
+ @db.execute_batch(DDL)
62
+ end
63
+ # The WITHOUT ROWID PRIMARY KEY (scope, key) is already the prefix index —
64
+ # there is no extra index to create.
65
+ rescue ::SQLite3::Exception => e
66
+ raise Insika::StoreError, "failed to open #{path}: #{e.message}"
67
+ end
68
+
69
+ # Short retry for SQLITE_BUSY at INITIALIZATION (the WAL-switch race at
70
+ # multi-process boot). ~10 attempts × 60ms ≈ 0.6s worst case — enough
71
+ # for the winning process to finish the journal-mode switch. Only
72
+ # here; the hot path uses `busy_timeout` + the in-process semaphore.
73
+ def with_busy_retry(attempts: 10, backoff: 0.06)
74
+ tries = 0
75
+ begin
76
+ yield
77
+ rescue ::SQLite3::Exception => e
78
+ raise unless e.message =~ /lock|busy/i
79
+
80
+ tries += 1
81
+ raise if tries >= attempts
82
+
83
+ sleep(backoff)
84
+ retry
85
+ end
86
+ end
87
+ private :with_busy_retry
88
+
89
+ def close
90
+ @db&.close
91
+ nil
92
+ end
93
+
94
+ def get(scope, key)
95
+ row = @db.get_first_value(
96
+ "SELECT value FROM kv WHERE scope = ? AND key = ?", [scope, key]
97
+ )
98
+ row.nil? ? nil : @serializer.parse(row)
99
+ rescue ::SQLite3::Exception => e
100
+ raise Insika::StoreError, e.message
101
+ end
102
+
103
+ def set(scope, key, value)
104
+ serialized = serialize(value) # fail-fast BEFORE writing
105
+ transaction do
106
+ @db.execute(
107
+ "INSERT OR REPLACE INTO kv (scope, key, value, updated_at) " \
108
+ "VALUES (?, ?, ?, ?)",
109
+ [scope, key, serialized, Time.now.utc.iso8601]
110
+ )
111
+ end
112
+ value
113
+ end
114
+
115
+ def delete(scope, key)
116
+ transaction do
117
+ @db.execute("DELETE FROM kv WHERE scope = ? AND key = ?", [scope, key])
118
+ @db.changes.positive?
119
+ end
120
+ end
121
+
122
+ def list(scope, prefix = nil)
123
+ keys = @db.execute(
124
+ "SELECT key FROM kv WHERE scope = ? ORDER BY key", [scope]
125
+ ).map(&:first)
126
+ prefix ? keys.select { |k| k.start_with?(prefix) } : keys
127
+ rescue ::SQLite3::Exception => e
128
+ raise Insika::StoreError, e.message
129
+ end
130
+
131
+ # BEGIN IMMEDIATE ... COMMIT/ROLLBACK, serialized by the semaphore.
132
+ # A nested one reuses the outer transaction.
133
+ def transaction(&blk)
134
+ return yield if @tx_owner == Fiber.current
135
+
136
+ @write_semaphore.acquire do
137
+ begin
138
+ @db.transaction(:immediate)
139
+ @tx_owner = Fiber.current
140
+ result = yield
141
+ @db.commit
142
+ result
143
+ rescue StandardError
144
+ @db.rollback if @db.transaction_active?
145
+ raise
146
+ ensure
147
+ @tx_owner = nil
148
+ end
149
+ end
150
+ rescue ::SQLite3::Exception => e
151
+ raise Insika::StoreError, e.message
152
+ end
153
+
154
+ private
155
+
156
+ # Enforces the contract's type model at the boundary:
157
+ # Symbol/symbol-key become String; a type outside the JSON model ->
158
+ # StoreError on WRITE (fail-fast; never writes garbage).
159
+ #
160
+ # Does NOT use `generate(strict: true)`: under json 2.7.1 (the version that
161
+ # ships with ruby 3.3.5 pinned in Gemfile.lock) `strict` REJECTS Symbol,
162
+ # which would violate the Symbol coercion. The explicit validation
163
+ # is independent of the json version and gives the SAME semantics as Memory.
164
+ def serialize(value)
165
+ ensure_jsonable!(value)
166
+ @serializer.generate(value)
167
+ rescue JSON::GeneratorError => e
168
+ raise Insika::StoreError, "value not serializable: #{e.message}"
169
+ end
170
+
171
+ def ensure_jsonable!(value)
172
+ case value
173
+ when *JSONABLE then nil
174
+ when Array then value.each { |v| ensure_jsonable!(v) }
175
+ when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
176
+ else
177
+ raise Insika::StoreError,
178
+ "value not serializable: #{value.class} not allowed in JSON"
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end