insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -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 +375 -0
- data/docs/SKILLS.md +284 -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 +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -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/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -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_skill.rb +43 -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/issue_tenant_token.rb +41 -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/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -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 +112 -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 +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -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 +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -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 +132 -0
- data/lib/insika/executor.rb +1995 -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/loop_detector.rb +143 -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 +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -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 +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -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/reliability.rb +185 -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 +102 -0
- data/lib/insika/safety/moderator.rb +94 -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/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -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 +220 -0
- data/lib/insika/skill_store.rb +127 -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 +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -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 +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -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/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -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_output_compressor.rb +100 -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 +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -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 +162 -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 +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Contract suite for Insika::Store, exported for third-party
|
|
4
|
+
# backends — a gem like `insika-pg` writes its spec against THIS
|
|
5
|
+
# file, not against a read of stores/sqlite.rb:
|
|
6
|
+
#
|
|
7
|
+
# require "insika/testing/store_contract"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe Insika::Stores::PG do
|
|
10
|
+
# subject(:store) { described_class.new(url:) }
|
|
11
|
+
# it_behaves_like "an Insika store"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Two groups, on purpose:
|
|
15
|
+
#
|
|
16
|
+
# - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
|
|
17
|
+
# suite is honest — a test that passes on Memory passes on SQLite). The
|
|
18
|
+
# including group defines `store` (an empty, ready backend).
|
|
19
|
+
# - "an Insika store safe for N workers" — OPT-IN; the multi-worker half. The
|
|
20
|
+
# including group ALSO defines `store_factory`, a callable returning ANOTHER
|
|
21
|
+
# connection to the SAME underlying backend. A backend that cannot serialize
|
|
22
|
+
# transactions across connections must not include it — Memory is the
|
|
23
|
+
# canonical example, and a single-connection backend has nothing to prove.
|
|
24
|
+
# Anything that wants to sit under `WEB_CONCURRENCY > 1` has to.
|
|
25
|
+
#
|
|
26
|
+
# Do not include backend-specific cases here (file durability, WAL, boot
|
|
27
|
+
# races) — those belong to the backend's own spec.
|
|
28
|
+
RSpec.shared_examples "an Insika store" do
|
|
29
|
+
describe "#get / #set (round-trip)" do
|
|
30
|
+
it " preserves Hash with string keys" do #
|
|
31
|
+
store.set("s", "k", { "a" => 1, "b" => [1, 2] })
|
|
32
|
+
expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it " preserves Array" do #
|
|
36
|
+
store.set("s", "k", [1, "x", true, nil])
|
|
37
|
+
expect(store.get("s", "k")).to eq([1, "x", true, nil])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it " preserves String" do #
|
|
41
|
+
store.set("s", "k", "texto")
|
|
42
|
+
expect(store.get("s", "k")).to eq("texto")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it " preserves Integer" do #
|
|
46
|
+
store.set("s", "k", 42)
|
|
47
|
+
value = store.get("s", "k")
|
|
48
|
+
expect(value).to eq(42)
|
|
49
|
+
expect(value).to be_a(Integer)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it " preserves Float" do #
|
|
53
|
+
store.set("s", "k", 3.14)
|
|
54
|
+
value = store.get("s", "k")
|
|
55
|
+
expect(value).to eq(3.14)
|
|
56
|
+
expect(value).to be_a(Float)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it " preserves booleans" do #
|
|
60
|
+
store.set("s", "t", true)
|
|
61
|
+
store.set("s", "f", false)
|
|
62
|
+
expect(store.get("s", "t")).to be(true)
|
|
63
|
+
expect(store.get("s", "f")).to be(false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it " preserves nil written without exception" do #
|
|
67
|
+
store.set("s", "k", nil)
|
|
68
|
+
expect(store.get("s", "k")).to be_nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it " converts Symbols (keys and values) to Strings" do #
|
|
72
|
+
store.set("s", "k", { chave: :valor })
|
|
73
|
+
expect(store.get("s", "k")).to eq({ "chave" => "valor" })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it " returns nil for an absent key, never an exception" do #
|
|
77
|
+
expect(store.get("s", "nao-existe")).to be_nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it " overwrites silently (last-write-wins)" do #
|
|
81
|
+
store.set("s", "k", "primeiro")
|
|
82
|
+
store.set("s", "k", "segundo")
|
|
83
|
+
expect(store.get("s", "k")).to eq("segundo")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it " set returns the same object passed in (not the round-trip)" do #
|
|
87
|
+
obj = { "a" => 1 }
|
|
88
|
+
expect(store.set("s", "k", obj)).to equal(obj)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe "#delete" do
|
|
93
|
+
it " removes existing and returns true" do #
|
|
94
|
+
store.set("s", "k", 1)
|
|
95
|
+
expect(store.delete("s", "k")).to be(true)
|
|
96
|
+
expect(store.get("s", "k")).to be_nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it " returns false for a nonexistent key" do #
|
|
100
|
+
expect(store.delete("s", "k")).to be(false)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe "#list" do
|
|
105
|
+
it " returns scope keys sorted lexicographically" do #
|
|
106
|
+
store.set("s", "b", 1)
|
|
107
|
+
store.set("s", "a", 1)
|
|
108
|
+
store.set("s", "c", 1)
|
|
109
|
+
expect(store.list("s")).to eq(%w[a b c])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it " filters by prefix with start_with? (not include?)" do #
|
|
113
|
+
store.set("s", "task:1", 1)
|
|
114
|
+
store.set("s", "task:2", 1)
|
|
115
|
+
store.set("s", "checkpoint:1", 1)
|
|
116
|
+
store.set("s", "my-task:1", 1) # trap: contains "task:" but does not start with it
|
|
117
|
+
expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it " returns [] for an empty scope" do #
|
|
121
|
+
expect(store.list("s")).to eq([])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "sorts lexicographically, not numerically" do # edge case 2
|
|
125
|
+
store.set("s", "task:10", 1)
|
|
126
|
+
store.set("s", "task:2", 1)
|
|
127
|
+
expect(store.list("s", "task:")).to eq(%w[task:10 task:2])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe "scope isolation" do
|
|
132
|
+
it " keeps scopes independent in get/list/delete" do #
|
|
133
|
+
store.set("s1", "k", 1)
|
|
134
|
+
store.set("s2", "k", 2)
|
|
135
|
+
|
|
136
|
+
expect(store.get("s1", "k")).to eq(1)
|
|
137
|
+
expect(store.get("s2", "k")).to eq(2)
|
|
138
|
+
expect(store.list("s1")).to eq(%w[k])
|
|
139
|
+
|
|
140
|
+
store.delete("s1", "k")
|
|
141
|
+
expect(store.get("s2", "k")).to eq(2)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
describe "#transaction" do
|
|
146
|
+
it " returns the block's value" do #
|
|
147
|
+
expect(store.transaction { 42 }).to eq(42)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it " commits the block's writes" do #
|
|
151
|
+
store.transaction { store.set("s", "k", "commitado") }
|
|
152
|
+
expect(store.get("s", "k")).to eq("commitado")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it " does a real rollback of set AND delete when the block raises" do #
|
|
156
|
+
store.set("s", "manter", "antigo")
|
|
157
|
+
store.set("s", "apagar", "existe")
|
|
158
|
+
|
|
159
|
+
expect do
|
|
160
|
+
store.transaction do
|
|
161
|
+
store.set("s", "manter", "novo")
|
|
162
|
+
store.delete("s", "apagar")
|
|
163
|
+
raise "boom"
|
|
164
|
+
end
|
|
165
|
+
end.to raise_error("boom")
|
|
166
|
+
|
|
167
|
+
# all the block's effects undone
|
|
168
|
+
expect(store.get("s", "manter")).to eq("antigo")
|
|
169
|
+
expect(store.get("s", "apagar")).to eq("existe")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it " reuses the outer transaction when nested" do #
|
|
173
|
+
store.set("s", "k", "antigo")
|
|
174
|
+
|
|
175
|
+
expect do
|
|
176
|
+
store.transaction do
|
|
177
|
+
store.transaction { store.set("s", "k", "novo") }
|
|
178
|
+
raise "boom"
|
|
179
|
+
end
|
|
180
|
+
end.to raise_error("boom")
|
|
181
|
+
|
|
182
|
+
# outer rollback undoes the inner set (no nesting error)
|
|
183
|
+
expect(store.get("s", "k")).to eq("antigo")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "serialization errors" do
|
|
188
|
+
it " raises StoreError and does not write a non-serializable value" do #
|
|
189
|
+
expect do
|
|
190
|
+
store.set("s", "k", Object.new)
|
|
191
|
+
end.to raise_error(Insika::StoreError)
|
|
192
|
+
expect(store.get("s", "k")).to be_nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Multi-worker safety. Every claim (outbox, delegation
|
|
198
|
+
# sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
|
|
199
|
+
# atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
|
|
200
|
+
# only yields passes all 22 cases above and still double-claims under two
|
|
201
|
+
# workers, silently. These cases close that hole, across REAL concurrent
|
|
202
|
+
# connections (threads, each on its own handle from `store_factory`).
|
|
203
|
+
#
|
|
204
|
+
# The `sleep` inside each transaction is deliberate: it holds the read state
|
|
205
|
+
# open long enough that a backend without isolation ALWAYS lets a second
|
|
206
|
+
# connection through, while a correct backend serializes the writers. Without
|
|
207
|
+
# it a wrong backend could pass by scheduling luck — the failure relies on.
|
|
208
|
+
RSpec.shared_examples "an Insika store safe for N workers" do
|
|
209
|
+
# Starts N threads, each holding its OWN connection to the same backend,
|
|
210
|
+
# releases them together, and returns each block's value. Connections are
|
|
211
|
+
# closed on the way out when the backend has a #close.
|
|
212
|
+
def with_concurrent_connections(n, &blk)
|
|
213
|
+
ready = Queue.new
|
|
214
|
+
go = Queue.new
|
|
215
|
+
threads = n.times.map do
|
|
216
|
+
Thread.new do
|
|
217
|
+
conn = store_factory.call
|
|
218
|
+
ready << true
|
|
219
|
+
go.pop # hold every connection at the line, then release them together
|
|
220
|
+
blk.call(conn)
|
|
221
|
+
ensure
|
|
222
|
+
conn.close if conn.respond_to?(:close)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
n.times { ready.pop }
|
|
226
|
+
go.close # a closed queue pops nil immediately: the starting gun
|
|
227
|
+
threads.map(&:value)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The outbox/delegation claim, verbatim in shape: read the status, and only
|
|
231
|
+
# the connection that still sees "pending" may flip it. A correct backend
|
|
232
|
+
# serializes the transactions, so exactly ONE of the 8 observes "pending".
|
|
233
|
+
it "claims a pending key exactly once across concurrent connections" do
|
|
234
|
+
store.set("jobs", "job:1", "pending")
|
|
235
|
+
|
|
236
|
+
claims = with_concurrent_connections(8) do |conn|
|
|
237
|
+
conn.transaction do
|
|
238
|
+
next unless conn.get("jobs", "job:1") == "pending"
|
|
239
|
+
|
|
240
|
+
sleep 0.01 # hold the read open: a wrong backend lets everyone through
|
|
241
|
+
conn.set("jobs", "job:1", "claimed")
|
|
242
|
+
true
|
|
243
|
+
end
|
|
244
|
+
end.count(true)
|
|
245
|
+
|
|
246
|
+
expect(claims).to eq(1)
|
|
247
|
+
expect(store.get("jobs", "job:1")).to eq("claimed")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The lost-update shape the claim generalizes from: read-modify-write of a
|
|
251
|
+
# counter. A backend whose transaction yields without isolation drops
|
|
252
|
+
# increments whenever two connections overlap.
|
|
253
|
+
it "does not lose updates across concurrent connections" do
|
|
254
|
+
store.set("meters", "hits", 0)
|
|
255
|
+
connections = 4
|
|
256
|
+
increments = 10
|
|
257
|
+
|
|
258
|
+
with_concurrent_connections(connections) do |conn|
|
|
259
|
+
increments.times do
|
|
260
|
+
conn.transaction do
|
|
261
|
+
current = conn.get("meters", "hits")
|
|
262
|
+
sleep 0.001 # widen the window a wrong backend loses updates through
|
|
263
|
+
conn.set("meters", "hits", current + 1)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
expect(store.get("meters", "hits")).to eq(connections * increments)
|
|
269
|
+
end
|
|
270
|
+
end
|
data/lib/insika/tick.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# The periodic tick: durability stops waiting for a reboot. One
|
|
7
|
+
# pass does two things, in this order:
|
|
8
|
+
#
|
|
9
|
+
# 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
|
|
10
|
+
# (or process) recorded and never claimed. Ungated: every record carries
|
|
11
|
+
# its own transactional claim, so N workers draining is safe.
|
|
12
|
+
# 2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
|
|
13
|
+
# bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
|
|
14
|
+
# exactly one worker per window sweeps. The staleness threshold is the
|
|
15
|
+
# liveness gate: a live :running turn is bounded by turn_timeout, so
|
|
16
|
+
# anything untouched past it cannot be alive.
|
|
17
|
+
#
|
|
18
|
+
# It is NOT a job queue: no schedules, no priorities, no fan-out. The
|
|
19
|
+
# refinement hook once pictured here is dropped by merit —
|
|
20
|
+
# docs/REFINEMENT.md's "no scheduler in the engine" stands.
|
|
21
|
+
class Tick
|
|
22
|
+
# 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
|
|
23
|
+
# default turn_timeout (300s) — the rule, not the number: the threshold
|
|
24
|
+
# must exceed the deployment's largest turn_timeout, or the sweep would
|
|
25
|
+
# judge live turns orphaned.
|
|
26
|
+
DEFAULT_INTERVAL = 60
|
|
27
|
+
DEFAULT_STALE_AFTER = 900
|
|
28
|
+
|
|
29
|
+
SCOPE = "tick"
|
|
30
|
+
KEY = "claim"
|
|
31
|
+
|
|
32
|
+
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
33
|
+
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
34
|
+
sleeper: nil)
|
|
35
|
+
@store = store
|
|
36
|
+
@recovery = recovery
|
|
37
|
+
@channel_delivery = channel_delivery
|
|
38
|
+
@logger = logger
|
|
39
|
+
@interval = interval.to_i
|
|
40
|
+
@stale_after = stale_after.to_i
|
|
41
|
+
@sleeper = sleeper || method(:default_sleep)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enabled? = @interval.positive?
|
|
45
|
+
|
|
46
|
+
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
47
|
+
# specs call it directly. A StoreError propagates to the loop, which logs
|
|
48
|
+
# and keeps ticking.
|
|
49
|
+
def run_once
|
|
50
|
+
drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
|
|
51
|
+
summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
|
|
52
|
+
return summary unless claim_window
|
|
53
|
+
|
|
54
|
+
result = @recovery.run(stale_after: @stale_after)
|
|
55
|
+
summary.merge(resumed: result[:resumed], failed: result[:failed])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The loop, spawned as a child of the turn supervisor (the
|
|
59
|
+
# tick lives on the supervisor fiber — every serving arm gets it the moment
|
|
60
|
+
# `supervised = true` matters, with no arm edits). A failing pass logs and
|
|
61
|
+
# the loop continues: a sweeper that dies silently is the outage it exists
|
|
62
|
+
# to prevent. Restartable: when the supervisor is recreated its children
|
|
63
|
+
# died with it, so a stopped task is not a running one.
|
|
64
|
+
def start(parent:)
|
|
65
|
+
return false unless enabled?
|
|
66
|
+
return true if @task&.running?
|
|
67
|
+
|
|
68
|
+
@task = parent.async do |t|
|
|
69
|
+
t.annotate("insika-tick")
|
|
70
|
+
loop do
|
|
71
|
+
@sleeper.call(@interval)
|
|
72
|
+
run_once
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
log(:warn, "tick failed: #{e.class}: #{e.message}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# One sweeper per window across N workers. Unlike the boot
|
|
83
|
+
# claim (one key per generation), the tick reuses a SINGLE key with a
|
|
84
|
+
# timestamp — a key per minute would be a slow leak in the store. The
|
|
85
|
+
# read-check-write rides Store#transaction like every claim:
|
|
86
|
+
# two workers racing the window serialize on the backend's lock and exactly
|
|
87
|
+
# one sweeps.
|
|
88
|
+
def claim_window
|
|
89
|
+
now = Time.now.utc
|
|
90
|
+
@store.transaction do
|
|
91
|
+
current = @store.get(SCOPE, KEY)
|
|
92
|
+
last = current && begin
|
|
93
|
+
Time.iso8601(current["claimed_at"].to_s)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil # a corrupted claim is not a claim — take the window
|
|
96
|
+
end
|
|
97
|
+
if last.nil? || (now - last) >= @interval
|
|
98
|
+
@store.set(SCOPE, KEY, { "claimed_at" => now.iso8601 })
|
|
99
|
+
true
|
|
100
|
+
else
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Async when there is a reactor (production: the interval must not block
|
|
107
|
+
# the worker), plain sleep otherwise (specs driving the loop by hand use an
|
|
108
|
+
# injected sleeper anyway).
|
|
109
|
+
def default_sleep(seconds)
|
|
110
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
111
|
+
task ? task.sleep(seconds) : sleep(seconds)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Same contract as Recovery's: logging is pure observability and must never
|
|
115
|
+
# alter the flow.
|
|
116
|
+
def log(level, message)
|
|
117
|
+
@logger&.public_send(level, "[tick] #{message}")
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Cheap token estimate behind an interface.
|
|
5
|
+
# Default: text.length / 4 (off by ~±15%, absorbed by the budget margin).
|
|
6
|
+
# Swappable for a real tokenizer without touching the Builder: any object that
|
|
7
|
+
# responds to #estimate(text) -> Integer works as a replacement (injected at the
|
|
8
|
+
# composition root).
|
|
9
|
+
module TokenEstimator
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def estimate(text)
|
|
13
|
+
text.to_s.length.ceildiv(4)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Multi-tenant credentials (WS1): per-tenant tokens plus the operator token,
|
|
9
|
+
# stored ONLY as SHA-256 hashes — the plaintext is returned once at issue time
|
|
10
|
+
# and is never persisted, logged or evented. Every resolution is a hash lookup,
|
|
11
|
+
# so reading the store yields nothing usable. Behind any Insika::Store, like
|
|
12
|
+
# the other domain stores.
|
|
13
|
+
#
|
|
14
|
+
# A record describes ONE principal the edge can resolve a Bearer to:
|
|
15
|
+
# role: "operator" (tenant_id nil — the historical single credential)
|
|
16
|
+
# "tenant" (scoped to tenant_id)
|
|
17
|
+
# status: "active" | "revoked"
|
|
18
|
+
# A revoked record stops resolving immediately; a tenant's rotation is
|
|
19
|
+
# revoke-all + issue (the spec's rule — revoking one tenant's token never
|
|
20
|
+
# touches another tenant's: every cell is (id)- or (hash)-scoped cells).
|
|
21
|
+
class TokenStore
|
|
22
|
+
SCOPE = "tenant_tokens"
|
|
23
|
+
|
|
24
|
+
Record = Data.define(:id, :token_hash, :role, :tenant_id, :label,
|
|
25
|
+
:status, :created_at, :revoked_at) do
|
|
26
|
+
def active? = status.to_s == "active"
|
|
27
|
+
def tenant? = role.to_s == "tenant"
|
|
28
|
+
def to_h
|
|
29
|
+
{ id: id, role: role, tenant_id: tenant_id, label: label,
|
|
30
|
+
status: status, created_at: created_at, revoked_at: revoked_at }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The token a caller receives from a successful issue/rotate — the ONLY
|
|
35
|
+
# moment the plaintext exists in the process. `token` is never stored.
|
|
36
|
+
Issue = Data.define(:id, :token)
|
|
37
|
+
|
|
38
|
+
def initialize(store:)
|
|
39
|
+
@store = store
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Issues a token for tenant_id (nil = an OPERATOR token). -> Issue. The
|
|
43
|
+
# token is shown exactly once; there is no `get_token` — lost = rotate.
|
|
44
|
+
def issue(tenant_id: nil, label: "default")
|
|
45
|
+
validate_tenant_id!(tenant_id)
|
|
46
|
+
token = SecureRandom.hex(32)
|
|
47
|
+
hash = digest(token)
|
|
48
|
+
id = SecureRandom.uuid
|
|
49
|
+
@store.transaction do
|
|
50
|
+
@store.set(SCOPE, record_key(id), {
|
|
51
|
+
"id" => id, "token_hash" => hash,
|
|
52
|
+
"role" => tenant_id ? "tenant" : "operator",
|
|
53
|
+
"tenant_id" => tenant_id, "label" => label.to_s,
|
|
54
|
+
"status" => "active",
|
|
55
|
+
"created_at" => Time.now.utc.iso8601, "revoked_at" => nil
|
|
56
|
+
})
|
|
57
|
+
@store.set(SCOPE, hash_key(hash), id)
|
|
58
|
+
end
|
|
59
|
+
Issue.new(id: id, token: token)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Active-token record for token_id; a revoked one reads WHO it was but not
|
|
63
|
+
# as resolvable. -> Record | nil.
|
|
64
|
+
def find(id)
|
|
65
|
+
return nil if id.to_s.empty?
|
|
66
|
+
|
|
67
|
+
record = @store.get(SCOPE, record_key(id.to_s))
|
|
68
|
+
record && to_record(record)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The edge resolution: -> Record (active) | nil. A revoked token is
|
|
72
|
+
# indistinguishable from a missing one (fail-closed: the Bearer just 401s).
|
|
73
|
+
def resolve(token)
|
|
74
|
+
return nil if token.to_s.empty?
|
|
75
|
+
|
|
76
|
+
id = @store.get(SCOPE, hash_key(digest(token)))
|
|
77
|
+
return nil if id.nil?
|
|
78
|
+
|
|
79
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
80
|
+
record&.active? ? record : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> bool: true only for an ACTIVE record (revoking an already-revoked/unknown
|
|
84
|
+
# id is false — a no-op, never an error). The read-modify-write rides
|
|
85
|
+
# `@store.transaction` (the repo's rule — WS1): `next false`, NOT `return
|
|
86
|
+
# false`, or the non-local return would skip the COMMIT and leak the open
|
|
87
|
+
# transaction (the same trap as the budget ledger's mark_alert).
|
|
88
|
+
def revoke(id)
|
|
89
|
+
@store.transaction do
|
|
90
|
+
record = find(id)
|
|
91
|
+
next false unless record&.active?
|
|
92
|
+
|
|
93
|
+
flipped = record.to_h.merge(status: "revoked", revoked_at: Time.now.utc.iso8601)
|
|
94
|
+
@store.set(SCOPE, record_key(id), stringify(flipped))
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Revokes every ACTIVE token of a tenant; the hash-index cells stay (they
|
|
100
|
+
# resolve to a revoked record -> nil). -> count of records revoked. Does NOT
|
|
101
|
+
# touch the operator token or any other tenant.
|
|
102
|
+
def revoke_all(tenant_id:)
|
|
103
|
+
ids = active_token_ids.select do |id|
|
|
104
|
+
record = to_record(@store.get(SCOPE, record_key(id)))
|
|
105
|
+
record&.tenant? && record.tenant_id.to_s == tenant_id.to_s
|
|
106
|
+
end
|
|
107
|
+
ids.count { |id| revoke(id) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rotation: revoke the tenant's active tokens, issue a fresh one. Both in
|
|
111
|
+
# one transaction -> a crashed half-rotation never leaves the tenant with
|
|
112
|
+
# NOTHING valid. -> { revoked: n, issue: Issue }.
|
|
113
|
+
def rotate(tenant_id:, label: "default", now: Time.now)
|
|
114
|
+
@store.transaction do
|
|
115
|
+
revoked = revoke_all(tenant_id: tenant_id)
|
|
116
|
+
{ revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every active token id (used by revoke_all). Reads the id-keyed cells.
|
|
121
|
+
def active_token_ids
|
|
122
|
+
@store.list(SCOPE, RECORD_PREFIX).filter_map do |key|
|
|
123
|
+
id = key.delete_prefix(RECORD_PREFIX)
|
|
124
|
+
record = @store.get(SCOPE, record_key(id))
|
|
125
|
+
record && record["status"] == "active" ? id : nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# The tenant id IS the session/task namespace prefix ("<tenant>:<id>" — WS1).
|
|
132
|
+
# A ":" lets a second tenant forge an id that lands on the FIRST tenant's
|
|
133
|
+
# namespace: T1="loja" + session id "adma:x" => "loja:adma:x", which is the
|
|
134
|
+
# cell T2="loja:adma" would claim with id "x" (WS1). Guarded at the single
|
|
135
|
+
# chokepoint both issue and rotate flow through.
|
|
136
|
+
def validate_tenant_id!(tenant_id)
|
|
137
|
+
return if tenant_id.nil?
|
|
138
|
+
|
|
139
|
+
raise Insika::ValidationError, "tenant_id is required" if Insika::Coercion.blank?(tenant_id)
|
|
140
|
+
raise Insika::ValidationError, "tenant_id must not contain ':'" if tenant_id.to_s.include?(":")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def digest(token)
|
|
144
|
+
Digest::SHA256.hexdigest(token)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
RECORD_PREFIX = "r:"
|
|
148
|
+
HASH_PREFIX = "h:"
|
|
149
|
+
|
|
150
|
+
def record_key(id) = "#{RECORD_PREFIX}#{id}"
|
|
151
|
+
def hash_key(hash) = "#{HASH_PREFIX}#{hash}"
|
|
152
|
+
|
|
153
|
+
def to_record(record)
|
|
154
|
+
return nil if record.nil?
|
|
155
|
+
|
|
156
|
+
Record.new(
|
|
157
|
+
id: record["id"], token_hash: record["token_hash"],
|
|
158
|
+
role: record["role"], tenant_id: record["tenant_id"],
|
|
159
|
+
label: record["label"], status: record["status"],
|
|
160
|
+
created_at: record["created_at"], revoked_at: record["revoked_at"]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stringify(hash)
|
|
165
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|