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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- 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
|
data/lib/insika/store.rb
ADDED
|
@@ -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
|