insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# Settings — drill-down (T2): section list | selected section's form(s).
|
|
2
2
|
@section (general|models|llm) comes from ?s= and drives the detail. %>
|
|
3
3
|
<% sections = [
|
|
4
|
-
["general", "General", "streaming, timeouts
|
|
4
|
+
["general", "General", "streaming, timeouts"],
|
|
5
5
|
["models", "Model defaults", "platform model layer (Chat › Agent › platform)"],
|
|
6
6
|
["edge", "Edge limits", "rate-limit per chat, token ceiling per agent"],
|
|
7
7
|
["evals", "Evals", "who grades a rubric, and how a panel agrees"],
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
<button type="submit" form="set-general" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
|
|
28
28
|
</div>
|
|
29
29
|
<div class="drill-pane-body">
|
|
30
|
-
<p class="muted">Streaming
|
|
30
|
+
<p class="muted">Streaming and timeouts — saved via <code>update_settings</code> (durable, applies next turn).</p>
|
|
31
31
|
<form id="set-general" method="post" action="/studio/settings">
|
|
32
32
|
<%== csrf_tag %>
|
|
33
33
|
<label class="check">
|
|
@@ -39,12 +39,9 @@
|
|
|
39
39
|
<label>max_retries<input type="text" name="max_retries" value="<%= @settings["max_retries"] %>" inputmode="numeric"></label>
|
|
40
40
|
<label>turn_timeout (s)<input type="text" name="turn_timeout" value="<%= @settings["turn_timeout"] %>" inputmode="numeric"></label>
|
|
41
41
|
<label>tool_timeout (s)<input type="text" name="tool_timeout" value="<%= @settings["tool_timeout"] %>" inputmode="numeric"></label>
|
|
42
|
+
<label>Memory TTL (days)<input type="text" name="memory_ttl_days" value="<%= @settings["memory_ttl_days"] %>" inputmode="numeric" placeholder="blank = off"></label>
|
|
42
43
|
</div>
|
|
43
|
-
<
|
|
44
|
-
<input type="checkbox" name="compaction_enabled" value="1"<%== " checked" if @settings.dig("compaction", "enabled") %>>
|
|
45
|
-
context compaction
|
|
46
|
-
</label>
|
|
47
|
-
<label>compaction.keep_last (messages)<input type="text" name="keep_last" value="<%= @settings.dig("compaction", "keep_last") %>" inputmode="numeric"></label>
|
|
44
|
+
<p class="muted">Memory TTL: how many days a customer memory cell lives before the daily sweep prunes it (per-fact <code>expires_at</code> overrides always win). Editing here sets the platform default for <strong>every</strong> cell; a per-tenant map authored in the settings record is replaced by this save.</p>
|
|
48
45
|
</form>
|
|
49
46
|
</div>
|
|
50
47
|
|
|
@@ -58,11 +55,11 @@
|
|
|
58
55
|
<form id="set-models" method="post" action="/studio/settings/models">
|
|
59
56
|
<%== csrf_tag %>
|
|
60
57
|
<div class="grid-2">
|
|
61
|
-
<label>default_model <span class="muted">(agents without their own model)</span><input type="text" name="default_model" value="<%= @settings["default_model"] %>" placeholder="deepseek-
|
|
58
|
+
<label>default_model <span class="muted">(agents without their own model)</span><input type="text" name="default_model" value="<%= @settings["default_model"] %>" placeholder="deepseek-v4-flash"></label>
|
|
62
59
|
<label>default_provider <span class="muted">(optional)</span><input type="text" name="default_provider" value="<%= @settings["default_provider"] %>" placeholder="deepseek"></label>
|
|
63
60
|
</div>
|
|
64
|
-
<label>fallback_models <span class="muted">(ordered chain; <code>provider/model</code> or <code>model</code>, comma or newline)</span><input type="text" name="fallback_models" value="<%= Array(@settings["fallback_models"]).join(", ") %>" placeholder="deepseek/deepseek-
|
|
65
|
-
<label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span><input type="text" name="utility_model" value="<%= @settings["utility_model"] %>" placeholder="deepseek-
|
|
61
|
+
<label>fallback_models <span class="muted">(ordered chain; <code>provider/model</code> or <code>model</code>, comma or newline)</span><input type="text" name="fallback_models" value="<%= Array(@settings["fallback_models"]).join(", ") %>" placeholder="deepseek/deepseek-v4-flash, openai/gpt-4o-mini"></label>
|
|
62
|
+
<label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span><input type="text" name="utility_model" value="<%= @settings["utility_model"] %>" placeholder="deepseek-v4-flash"></label>
|
|
66
63
|
<label>reasoning <span class="muted">(global default — off/on/effort; blank = provider default)</span>
|
|
67
64
|
<%== thinking_select("thinking", @settings["thinking"], "provider default") %>
|
|
68
65
|
</label>
|
|
@@ -107,7 +104,7 @@
|
|
|
107
104
|
<p class="muted">Who grades a <a href="/studio/evals">case</a>'s rubric. <strong>One model per line</strong> as <code>provider/model</code> — several lines make a PANEL, and two different models disagreeing is the signal worth having (sampling one model N times mostly measures its own variance). Empty = deterministic assertions only, and rubric'd cases read as <code>judge_pending</code>.</p>
|
|
108
105
|
<form id="set-evals" method="post" action="/studio/settings/evals">
|
|
109
106
|
<%== csrf_tag %>
|
|
110
|
-
<label>judges<textarea name="judges" rows="4" spellcheck="false" placeholder="deepseek/deepseek-
|
|
107
|
+
<label>judges<textarea name="judges" rows="4" spellcheck="false" placeholder="deepseek/deepseek-v4-flash openai/gpt-5-mini"><%= Array(@settings.dig("evals", "judges")).map { |j| [j["provider"], j["model"]].compact.reject(&:empty?).join("/") }.join("\n") %></textarea></label>
|
|
111
108
|
<div class="grid-2">
|
|
112
109
|
<label>aggregate <span class="muted">(how the panel's scores combine)</span>
|
|
113
110
|
<select name="aggregate">
|
|
@@ -147,7 +144,7 @@
|
|
|
147
144
|
<label>auth_header<input type="text" name="auth_header" value="<%= p["auth_header"] %>" placeholder="Authorization"></label>
|
|
148
145
|
</div>
|
|
149
146
|
<label>api_key<input type="password" name="api_key" value="<%= p["api_key"] %>" autocomplete="off"></label>
|
|
150
|
-
<label>models <span class="muted">(comma or newline)</span><input type="text" name="models" value="<%= Array(p["models"]).join(", ") %>" placeholder="deepseek-
|
|
147
|
+
<label>models <span class="muted">(comma or newline)</span><input type="text" name="models" value="<%= Array(p["models"]).join(", ") %>" placeholder="deepseek-v4-flash, deepseek-reasoner"></label>
|
|
151
148
|
<div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Saving…">Save provider</button></div>
|
|
152
149
|
</form>
|
|
153
150
|
</div>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<%# Skills — drill-down (T2): master list | detail editor + availability.
|
|
2
2
|
Every skill route renders THIS view (index / new / :name); @selected drives
|
|
3
|
-
which skill is open (nil = none, "" = new).
|
|
3
|
+
which skill is open (nil = none, "" = new). @scope_agent set = editing THAT
|
|
4
|
+
agent's own version of the skill, not the shared one. %>
|
|
5
|
+
<% scope = @scope_agent %>
|
|
6
|
+
<% form_action = scope ? "/studio/agents/#{Rack::Utils.escape(scope)}/skills/#{Rack::Utils.escape(@selected.to_s)}" : "/studio/skills" %>
|
|
4
7
|
<div class="drill" data-controller="list-filter">
|
|
5
8
|
<aside class="drill-pane drill-master">
|
|
6
9
|
<div class="drill-pane-head">
|
|
@@ -36,15 +39,29 @@
|
|
|
36
39
|
<% new = @selected == "" %>
|
|
37
40
|
<div class="drill-pane-head">
|
|
38
41
|
<div class="title">
|
|
39
|
-
<
|
|
40
|
-
<strong class="mono"><%= new ? "New skill" : @selected %></strong>
|
|
42
|
+
<strong class="mono"><%= new ? "New skill" : @selected %><% if scope %> <span class="pill warn">specialized for <%= scope %></span><% end %></strong>
|
|
41
43
|
</div>
|
|
42
44
|
<button type="submit" form="skill-form" class="btn primary" data-turbo-submits-with="Saving…">Save skill</button>
|
|
43
45
|
</div>
|
|
44
46
|
<div class="drill-pane-body">
|
|
45
|
-
|
|
47
|
+
<%# The specialization is one MORE record, not a fork: the name stays the bare
|
|
48
|
+
shared name everywhere the model and the operator see it, and deleting the
|
|
49
|
+
override falls back to the shared body. %>
|
|
50
|
+
<% if scope %>
|
|
51
|
+
<div class="drill-section">
|
|
52
|
+
<p class="muted">This is <strong><%= scope %></strong>'s own version of <code><%= @selected %></code>. Every
|
|
53
|
+
other agent keeps the <a href="/studio/skills/<%= Rack::Utils.escape(@selected.to_s) %>">shared skill</a>,
|
|
54
|
+
and the name does not change — the allowlist, <code><available_skills></code> and
|
|
55
|
+
<code>load_skill</code> all still say <code><%= @selected %></code>.</p>
|
|
56
|
+
<form method="post" action="/studio/agents/<%= Rack::Utils.escape(scope) %>/skills/<%= Rack::Utils.escape(@selected.to_s) %>/delete">
|
|
57
|
+
<%== csrf_tag %>
|
|
58
|
+
<div class="actions"><button type="submit" data-turbo-confirm="Remove this specialization? <%= scope %> falls back to the shared skill.">Stop specializing</button></div>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
<form id="skill-form" method="post" action="<%= form_action %>" data-controller="dirty-guard">
|
|
46
63
|
<%== csrf_tag %>
|
|
47
|
-
<%
|
|
64
|
+
<% if !new && !scope %><input type="hidden" name="name" value="<%= @selected %>"><% end %>
|
|
48
65
|
<% if new %>
|
|
49
66
|
<label>name <span class="muted">(canonical — same as <code>name:</code> in the frontmatter)</span>
|
|
50
67
|
<input type="text" name="name" placeholder="my-skill" required>
|
|
@@ -56,29 +73,66 @@
|
|
|
56
73
|
</div>
|
|
57
74
|
</form>
|
|
58
75
|
|
|
59
|
-
<%
|
|
76
|
+
<% if !new && !scope %>
|
|
60
77
|
<div class="drill-section">
|
|
61
78
|
<div class="drill-section-head">
|
|
62
79
|
<h3>Availability</h3>
|
|
63
|
-
<span class="muted">
|
|
80
|
+
<span class="muted">per agent: <strong>allowed</strong> = can <code>load_skill</code> it; <strong>always on</strong> = the body
|
|
81
|
+
is in the prompt every turn. Both are the AGENT's decision, so one shared skill can be
|
|
82
|
+
always-on for one and discretionary for another. An "all" allowlist is left untouched.</span>
|
|
64
83
|
</div>
|
|
65
84
|
<form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/agents">
|
|
66
85
|
<%== csrf_tag %>
|
|
67
86
|
<% if @agents.empty? %>
|
|
68
87
|
<p class="muted">No agents configured.</p>
|
|
69
88
|
<% else %>
|
|
70
|
-
<div class="check-grid">
|
|
89
|
+
<div class="check-grid agent-grid">
|
|
71
90
|
<% @agents.each do |a| %>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
91
|
+
<% own = Array((@specialized || {})[@selected]).include?(a.id) %>
|
|
92
|
+
<div class="agent-check">
|
|
93
|
+
<div class="agent-check-name">
|
|
94
|
+
<span class="mono"><%= a.id %></span>
|
|
95
|
+
<% if own %>
|
|
96
|
+
<a class="pill warn" href="/studio/agents/<%= Rack::Utils.escape(a.id) %>/skills/<%= Rack::Utils.escape(@selected) %>">own version</a>
|
|
97
|
+
<% end %>
|
|
98
|
+
</div>
|
|
99
|
+
<label class="check tool-check">
|
|
100
|
+
<input type="checkbox" name="agent_ids[]" value="<%= a.id %>"<%== " checked" if skill_enabled_for?(a, @selected) %>>
|
|
101
|
+
<span class="mono">allowed</span>
|
|
102
|
+
</label>
|
|
103
|
+
<label class="check tool-check">
|
|
104
|
+
<input type="checkbox" name="eager_ids[]" value="<%= a.id %>"<%== " checked" if skill_eager_for?(a, @selected) %>>
|
|
105
|
+
<span class="mono">always on</span>
|
|
106
|
+
</label>
|
|
107
|
+
</div>
|
|
76
108
|
<% end %>
|
|
77
109
|
</div>
|
|
78
110
|
<div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Applying…">Apply to agents</button></div>
|
|
79
111
|
<% end %>
|
|
80
112
|
</form>
|
|
81
113
|
</div>
|
|
114
|
+
|
|
115
|
+
<%# Specializing is what keeps a shared skill shared: without it the only way
|
|
116
|
+
to give one agent a different version was to fork it under a new name and
|
|
117
|
+
edit the allowlist, which throws the sharing away. %>
|
|
118
|
+
<% others = @agents.reject { |a| Array((@specialized || {})[@selected]).include?(a.id) } %>
|
|
119
|
+
<% if others.any? %>
|
|
120
|
+
<div class="drill-section">
|
|
121
|
+
<div class="drill-section-head">
|
|
122
|
+
<h3>Specialize</h3>
|
|
123
|
+
<span class="muted">give ONE agent its own version of this skill, seeded from the body above — same name, no fork</span>
|
|
124
|
+
</div>
|
|
125
|
+
<form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/specialize">
|
|
126
|
+
<%== csrf_tag %>
|
|
127
|
+
<label>agent
|
|
128
|
+
<select name="agent_id" required>
|
|
129
|
+
<% others.each do |a| %><option value="<%= a.id %>"><%= a.id %></option><% end %>
|
|
130
|
+
</select>
|
|
131
|
+
</label>
|
|
132
|
+
<div class="actions"><button type="submit" data-turbo-submits-with="Creating…">Specialize for this agent</button></div>
|
|
133
|
+
</form>
|
|
134
|
+
</div>
|
|
135
|
+
<% end %>
|
|
82
136
|
<% end %>
|
|
83
137
|
</div>
|
|
84
138
|
<% end %>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
</div>
|
|
6
6
|
</header>
|
|
7
7
|
|
|
8
|
-
<p class="muted matrix-hint">GLOBAL prompts/rules: the runtime injects these files <strong>before</strong> each
|
|
8
|
+
<p class="muted matrix-hint">GLOBAL prompts/rules: the runtime injects these files <strong>before</strong> each agent's own identity, every turn. Store-backed and versioned (like per-agent prompts). Empty = no global injection (the prompt stays exactly as before).</p>
|
|
9
9
|
|
|
10
10
|
<% if @system_files.empty? %>
|
|
11
11
|
<div class="empty">No system files. Create one below (e.g. <code>HOUSE_RULES.md</code>) for a rule that applies to every agent.</div>
|
|
@@ -88,6 +88,19 @@
|
|
|
88
88
|
<% end %>
|
|
89
89
|
</section>
|
|
90
90
|
|
|
91
|
+
<% if @task.timing && !@task.timing.empty? %>
|
|
92
|
+
<section class="section">
|
|
93
|
+
<div class="section-head"><h2>Timing <span class="muted">· the turn's latency windows</span></h2></div>
|
|
94
|
+
<div class="card">
|
|
95
|
+
<ul class="kv">
|
|
96
|
+
<% @task.timing.each do |key, value| %>
|
|
97
|
+
<li><span class="k mono"><%= key %></span><span class="v mono"><%= value %></span></li>
|
|
98
|
+
<% end %>
|
|
99
|
+
</ul>
|
|
100
|
+
</div>
|
|
101
|
+
</section>
|
|
102
|
+
<% end %>
|
|
103
|
+
|
|
91
104
|
<section class="section">
|
|
92
105
|
<div class="section-head"><h2>Latest checkpoint</h2></div>
|
|
93
106
|
<% if @checkpoint.nil? %>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
<nav class="crumbs"><span>tasks</span></nav>
|
|
4
4
|
<h1>Tasks<span class="sub"><%= @tasks.size %> task(s)</span></h1>
|
|
5
5
|
</div>
|
|
6
|
-
<
|
|
6
|
+
<div class="head-actions">
|
|
7
|
+
<%== agent_filter_form("/studio/tasks", @agent) %>
|
|
8
|
+
<a class="btn" href="/studio/approvals">Approvals →</a>
|
|
9
|
+
</div>
|
|
7
10
|
</header>
|
|
8
11
|
|
|
9
12
|
<% if @tasks.empty? %>
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
<% a = @sel_agent %>
|
|
48
48
|
<div class="drill-pane-head">
|
|
49
49
|
<div class="title">
|
|
50
|
-
<nav class="crumbs"><a href="/studio/tools">tools</a> / <span><%= a.id %></span></nav>
|
|
51
50
|
<strong class="mono"><%= a.id %></strong>
|
|
52
51
|
</div>
|
|
53
52
|
<button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# Definition-time integrity of the subagent delegation graph (
|
|
5
|
-
#
|
|
4
|
+
# Definition-time integrity of the subagent delegation graph (
|
|
5
|
+
# A pure function over `{id => [child_ids]}`: detects CYCLES and
|
|
6
6
|
# computes the max delegation DEPTH, raising a typed SubagentError so the
|
|
7
7
|
# authoring Command (CreateAgent/UpdateAgent) fails cleanly and boot refuses a
|
|
8
8
|
# bad static set. Cycle + bounded depth are exactly Flue's definition-time
|
|
@@ -21,7 +21,7 @@ module Insika
|
|
|
21
21
|
|
|
22
22
|
# Max children a single `spawn_subagents` fan-out may run — also the concurrency
|
|
23
23
|
# bound (N concurrent LLM calls hit the provider rate limit + the per-agent token
|
|
24
|
-
# ceiling of
|
|
24
|
+
# ceiling of). Override with INSIKA_SUBAGENT_FANOUT_CAP.
|
|
25
25
|
DEFAULT_FANOUT_CAP = 8
|
|
26
26
|
|
|
27
27
|
module_function
|
data/lib/insika/task_actor.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Insika
|
|
|
9
9
|
# `await` as the cooperative SUSPENSION primitive. Cancellation/suspension only
|
|
10
10
|
# at stage boundaries — never in the middle of an operation.
|
|
11
11
|
class TaskActor
|
|
12
|
-
# `user_message` is posted by `Executor#steer_into_running`
|
|
12
|
+
# `user_message` is posted by `Executor#steer_into_running` and
|
|
13
13
|
# consumed by `SteerInjector` at a tool-batch boundary. `pause`/`resume` (operator),
|
|
14
14
|
# `approval` (human-in-the-loop), `timeout`/`heartbeat`
|
|
15
15
|
# (watchdog/liveness, observation).
|
|
@@ -61,14 +61,14 @@ module Insika
|
|
|
61
61
|
# the flag).
|
|
62
62
|
def pause_requested? = @pause_requested
|
|
63
63
|
|
|
64
|
-
#
|
|
64
|
+
# takes the steered messages and clears the buffer, WITHOUT
|
|
65
65
|
# observing anything else in the mailbox: whatever is not a `:user_message` is put
|
|
66
66
|
# back, in the order it arrived.
|
|
67
67
|
#
|
|
68
68
|
# Why not `drain!`: this runs INSIDE RubyLLM's tool loop (a `after_message`
|
|
69
69
|
# callback), and `drain!` raises on `:cancel`. Cancellation is only ever observed
|
|
70
70
|
# at the Executor's own stage boundaries — that is what keeps a tool batch one
|
|
71
|
-
# unit of work (
|
|
71
|
+
# unit of work (and for the same rule under `turn_timeout`).
|
|
72
72
|
# Injecting a message must not quietly become a new place a turn can die.
|
|
73
73
|
def take_user_messages!
|
|
74
74
|
@mailbox.size.times do
|
data/lib/insika/task_store.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Insika
|
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
Task = Data.define(:id, :status, :command, :session_id, :executions,
|
|
36
|
-
:mailbox_state, :created_at, :updated_at)
|
|
36
|
+
:mailbox_state, :timing, :created_at, :updated_at)
|
|
37
37
|
Execution = Data.define(:attempt, :started_at, :finished_at, :outcome, :error)
|
|
38
38
|
|
|
39
39
|
def initialize(store:)
|
|
@@ -131,7 +131,19 @@ module Insika
|
|
|
131
131
|
to_task(record)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
-
#
|
|
134
|
+
# writes the turn's timing breakdown onto the task record, once,
|
|
135
|
+
# when the turn completes. Best-effort by the caller's contract — a failure
|
|
136
|
+
# here must never re-fail an already-committed turn. `timing` is DATA (string
|
|
137
|
+
# keys, JSON types); deep_stringify makes a symbol-keyed to_h durable.
|
|
138
|
+
def record_timing(id, timing)
|
|
139
|
+
record = fetch!(id)
|
|
140
|
+
record["timing"] = deep_stringify(timing)
|
|
141
|
+
record["updated_at"] = timestamp
|
|
142
|
+
@store.set(SCOPE, key_for(id), record)
|
|
143
|
+
to_task(record)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# (`collect`): appends a fragment to a task's message while it is
|
|
135
147
|
# still waiting at the door. -> Task.
|
|
136
148
|
#
|
|
137
149
|
# ONLY on :queued, and that guard is the whole safety of the feature: once a
|
|
@@ -185,6 +197,13 @@ module Insika
|
|
|
185
197
|
end
|
|
186
198
|
end
|
|
187
199
|
|
|
200
|
+
# -> true | false (did it exist?). For Retention (WS8) — a purged session's
|
|
201
|
+
# tasks go with it, terminal tasks only (a live :running task must never
|
|
202
|
+
# be deleted under its own fiber).
|
|
203
|
+
def delete(id)
|
|
204
|
+
@store.delete(SCOPE, key_for(id))
|
|
205
|
+
end
|
|
206
|
+
|
|
188
207
|
private
|
|
189
208
|
|
|
190
209
|
def key_for(id)
|
|
@@ -228,6 +247,7 @@ module Insika
|
|
|
228
247
|
session_id: record["session_id"],
|
|
229
248
|
executions: record["executions"].map { |e| to_execution(e) },
|
|
230
249
|
mailbox_state: record["mailbox_state"],
|
|
250
|
+
timing: record["timing"],
|
|
231
251
|
created_at: record["created_at"],
|
|
232
252
|
updated_at: record["updated_at"]
|
|
233
253
|
)
|
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
6
|
module Telemetry
|
|
7
|
-
# Estimated cost of a turn, from a rates table given as DATA
|
|
7
|
+
# Estimated cost of a turn, from a rates table given as DATA.
|
|
8
8
|
#
|
|
9
9
|
# Insika never ships prices: they change weekly, differ per contract and per
|
|
10
10
|
# region, and a stale table in the engine would be worse than no number at all.
|
|
@@ -13,11 +13,11 @@ module Insika
|
|
|
13
13
|
#
|
|
14
14
|
# Rates are **USD per million tokens** (the unit every provider publishes):
|
|
15
15
|
#
|
|
16
|
-
# { "deepseek-
|
|
16
|
+
# { "deepseek-v4-flash" => { "input" => 0.27, "output" => 1.10,
|
|
17
17
|
# "cached_input" => 0.07, "cache_write" => 0.34 } }
|
|
18
18
|
#
|
|
19
19
|
# A key matches the model id the provider reports, with or without the
|
|
20
|
-
# `provider/` prefix (`deepseek/deepseek-
|
|
20
|
+
# `provider/` prefix (`deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
|
|
21
21
|
# entry above). An UNKNOWN model -> nil: a missing price is not a zero cost, so
|
|
22
22
|
# nothing is emitted and the dashboard shows a gap instead of a lie.
|
|
23
23
|
#
|
|
@@ -12,7 +12,7 @@ module Insika
|
|
|
12
12
|
# insika.data_tool), correlated by task_id. Latency is the span duration; the
|
|
13
13
|
# agent/tenant/model/tokens/cost ride as ATTRIBUTES.
|
|
14
14
|
#
|
|
15
|
-
# METRICS
|
|
15
|
+
# METRICS: the SAME events also feed counters and histograms, so
|
|
16
16
|
# volume/latency/tokens/cost are chartable WITHOUT span aggregation (which not
|
|
17
17
|
# every backend does, and none does cheaply at retention). Metric attributes are
|
|
18
18
|
# a deliberate LOW-CARDINALITY subset of the span attributes — never task_id or
|
data/lib/insika/telemetry.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "telemetry/pricing"
|
|
|
5
5
|
require_relative "telemetry/recorder"
|
|
6
6
|
|
|
7
7
|
module Insika
|
|
8
|
-
# OPT-IN observability
|
|
8
|
+
# OPT-IN observability: OTEL mounted at the edge, core untouched. Rides
|
|
9
9
|
# the Event Stream — the Recorder consumes the events and emits spans and metrics.
|
|
10
10
|
# Off (the default) -> `setup` returns nil and nothing is loaded or instrumented
|
|
11
11
|
# (parity, zero overhead). The OTEL gems are only REQUIRED lazily in `setup`
|
|
@@ -17,7 +17,7 @@ module Insika
|
|
|
17
17
|
# OTEL_* keys are the OpenTelemetry SDK's own env and stay verbatim; only our opt-in
|
|
18
18
|
# toggle is renamed (INSIKA_OTEL, with the HARNESS_OTEL alias still read).
|
|
19
19
|
#
|
|
20
|
-
# Metrics
|
|
20
|
+
# Metrics ride the same switch and the SAME standard env: the SDK
|
|
21
21
|
# registers a periodic metric reader when the OTLP metrics exporter is loadable,
|
|
22
22
|
# and `OTEL_METRICS_EXPORTER=none` turns metrics off while traces stay on. No
|
|
23
23
|
# Insika-specific toggle is invented for it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Contract suite for Insika::Store
|
|
4
|
-
# backends
|
|
3
|
+
# Contract suite for Insika::Store, exported for third-party
|
|
4
|
+
# backends — a gem like `insika-pg` writes its spec against THIS
|
|
5
5
|
# file, not against a read of stores/sqlite.rb:
|
|
6
6
|
#
|
|
7
7
|
# require "insika/testing/store_contract"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# it_behaves_like "an Insika store"
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
|
-
# Two groups, on purpose
|
|
14
|
+
# Two groups, on purpose:
|
|
15
15
|
#
|
|
16
16
|
# - "an Insika store" — universal; every backend passes EXACTLY it (L2: the
|
|
17
17
|
# suite is honest — a test that passes on Memory passes on SQLite). The
|
|
@@ -27,89 +27,89 @@
|
|
|
27
27
|
# races) — those belong to the backend's own spec.
|
|
28
28
|
RSpec.shared_examples "an Insika store" do
|
|
29
29
|
describe "#get / #set (round-trip)" do
|
|
30
|
-
it "
|
|
30
|
+
it " preserves Hash with string keys" do #
|
|
31
31
|
store.set("s", "k", { "a" => 1, "b" => [1, 2] })
|
|
32
32
|
expect(store.get("s", "k")).to eq({ "a" => 1, "b" => [1, 2] })
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
it "
|
|
35
|
+
it " preserves Array" do #
|
|
36
36
|
store.set("s", "k", [1, "x", true, nil])
|
|
37
37
|
expect(store.get("s", "k")).to eq([1, "x", true, nil])
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
it "
|
|
40
|
+
it " preserves String" do #
|
|
41
41
|
store.set("s", "k", "texto")
|
|
42
42
|
expect(store.get("s", "k")).to eq("texto")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
it "
|
|
45
|
+
it " preserves Integer" do #
|
|
46
46
|
store.set("s", "k", 42)
|
|
47
47
|
value = store.get("s", "k")
|
|
48
48
|
expect(value).to eq(42)
|
|
49
49
|
expect(value).to be_a(Integer)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
it "
|
|
52
|
+
it " preserves Float" do #
|
|
53
53
|
store.set("s", "k", 3.14)
|
|
54
54
|
value = store.get("s", "k")
|
|
55
55
|
expect(value).to eq(3.14)
|
|
56
56
|
expect(value).to be_a(Float)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
it "
|
|
59
|
+
it " preserves booleans" do #
|
|
60
60
|
store.set("s", "t", true)
|
|
61
61
|
store.set("s", "f", false)
|
|
62
62
|
expect(store.get("s", "t")).to be(true)
|
|
63
63
|
expect(store.get("s", "f")).to be(false)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
it "
|
|
66
|
+
it " preserves nil written without exception" do #
|
|
67
67
|
store.set("s", "k", nil)
|
|
68
68
|
expect(store.get("s", "k")).to be_nil
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
it "
|
|
72
|
-
store.set("s", "k", {
|
|
73
|
-
expect(store.get("s", "k")).to eq({ "
|
|
71
|
+
it " converts Symbols (keys and values) to Strings" do #
|
|
72
|
+
store.set("s", "k", { key: :value })
|
|
73
|
+
expect(store.get("s", "k")).to eq({ "key" => "value" })
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
it "
|
|
77
|
-
expect(store.get("s", "
|
|
76
|
+
it " returns nil for an absent key, never an exception" do #
|
|
77
|
+
expect(store.get("s", "missing-key")).to be_nil
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
it "
|
|
81
|
-
store.set("s", "k", "
|
|
82
|
-
store.set("s", "k", "
|
|
83
|
-
expect(store.get("s", "k")).to eq("
|
|
80
|
+
it " overwrites silently (last-write-wins)" do #
|
|
81
|
+
store.set("s", "k", "first")
|
|
82
|
+
store.set("s", "k", "second")
|
|
83
|
+
expect(store.get("s", "k")).to eq("second")
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
it "
|
|
86
|
+
it " set returns the same object passed in (not the round-trip)" do #
|
|
87
87
|
obj = { "a" => 1 }
|
|
88
88
|
expect(store.set("s", "k", obj)).to equal(obj)
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
describe "#delete" do
|
|
93
|
-
it "
|
|
93
|
+
it " removes existing and returns true" do #
|
|
94
94
|
store.set("s", "k", 1)
|
|
95
95
|
expect(store.delete("s", "k")).to be(true)
|
|
96
96
|
expect(store.get("s", "k")).to be_nil
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
it "
|
|
99
|
+
it " returns false for a nonexistent key" do #
|
|
100
100
|
expect(store.delete("s", "k")).to be(false)
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
describe "#list" do
|
|
105
|
-
it "
|
|
105
|
+
it " returns scope keys sorted lexicographically" do #
|
|
106
106
|
store.set("s", "b", 1)
|
|
107
107
|
store.set("s", "a", 1)
|
|
108
108
|
store.set("s", "c", 1)
|
|
109
109
|
expect(store.list("s")).to eq(%w[a b c])
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
it "
|
|
112
|
+
it " filters by prefix with start_with? (not include?)" do #
|
|
113
113
|
store.set("s", "task:1", 1)
|
|
114
114
|
store.set("s", "task:2", 1)
|
|
115
115
|
store.set("s", "checkpoint:1", 1)
|
|
@@ -117,7 +117,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
117
117
|
expect(store.list("s", "task:")).to eq(%w[task:1 task:2])
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
it "
|
|
120
|
+
it " returns [] for an empty scope" do #
|
|
121
121
|
expect(store.list("s")).to eq([])
|
|
122
122
|
end
|
|
123
123
|
|
|
@@ -128,8 +128,29 @@ RSpec.shared_examples "an Insika store" do
|
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
describe "#scopes" do
|
|
132
|
+
it " returns scope names sorted lexicographically" do #
|
|
133
|
+
store.set("zeta", "k", 1)
|
|
134
|
+
store.set("alpha", "k", 1)
|
|
135
|
+
store.set("alpha:child", "k", 1)
|
|
136
|
+
expect(store.scopes).to eq(["alpha", "alpha:child", "zeta"])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it " filters by prefix with start_with? (not include?)" do #
|
|
140
|
+
store.set("s", "k", 1)
|
|
141
|
+
store.set("s:child", "k", 1)
|
|
142
|
+
store.set("s2", "k", 1) # trap: contains "s" but does not start with "s:"
|
|
143
|
+
expect(store.scopes("s:")).to eq(["s:child"])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it " returns [] when nothing matches (or the store is empty)" do #
|
|
147
|
+
expect(store.scopes("nope")).to eq([])
|
|
148
|
+
expect(store.scopes).to eq([])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
131
152
|
describe "scope isolation" do
|
|
132
|
-
it "
|
|
153
|
+
it " keeps scopes independent in get/list/delete" do #
|
|
133
154
|
store.set("s1", "k", 1)
|
|
134
155
|
store.set("s2", "k", 2)
|
|
135
156
|
|
|
@@ -143,16 +164,16 @@ RSpec.shared_examples "an Insika store" do
|
|
|
143
164
|
end
|
|
144
165
|
|
|
145
166
|
describe "#transaction" do
|
|
146
|
-
it "
|
|
167
|
+
it " returns the block's value" do #
|
|
147
168
|
expect(store.transaction { 42 }).to eq(42)
|
|
148
169
|
end
|
|
149
170
|
|
|
150
|
-
it "
|
|
171
|
+
it " commits the block's writes" do #
|
|
151
172
|
store.transaction { store.set("s", "k", "commitado") }
|
|
152
173
|
expect(store.get("s", "k")).to eq("commitado")
|
|
153
174
|
end
|
|
154
175
|
|
|
155
|
-
it "
|
|
176
|
+
it " does a real rollback of set AND delete when the block raises" do #
|
|
156
177
|
store.set("s", "manter", "antigo")
|
|
157
178
|
store.set("s", "apagar", "existe")
|
|
158
179
|
|
|
@@ -169,7 +190,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
169
190
|
expect(store.get("s", "apagar")).to eq("existe")
|
|
170
191
|
end
|
|
171
192
|
|
|
172
|
-
it "
|
|
193
|
+
it " reuses the outer transaction when nested" do #
|
|
173
194
|
store.set("s", "k", "antigo")
|
|
174
195
|
|
|
175
196
|
expect do
|
|
@@ -185,7 +206,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
185
206
|
end
|
|
186
207
|
|
|
187
208
|
describe "serialization errors" do
|
|
188
|
-
it "
|
|
209
|
+
it " raises StoreError and does not write a non-serializable value" do #
|
|
189
210
|
expect do
|
|
190
211
|
store.set("s", "k", Object.new)
|
|
191
212
|
end.to raise_error(Insika::StoreError)
|
|
@@ -194,7 +215,7 @@ RSpec.shared_examples "an Insika store" do
|
|
|
194
215
|
end
|
|
195
216
|
end
|
|
196
217
|
|
|
197
|
-
# Multi-worker safety
|
|
218
|
+
# Multi-worker safety. Every claim (outbox, delegation
|
|
198
219
|
# sweep, recovery) is a read-check-write inside `transaction` — on SQLite that is
|
|
199
220
|
# atomic because the backend opens BEGIN IMMEDIATE; a backend whose transaction
|
|
200
221
|
# only yields passes all 22 cases above and still double-claims under two
|
|
@@ -204,7 +225,7 @@ end
|
|
|
204
225
|
# The `sleep` inside each transaction is deliberate: it holds the read state
|
|
205
226
|
# open long enough that a backend without isolation ALWAYS lets a second
|
|
206
227
|
# connection through, while a correct backend serializes the writers. Without
|
|
207
|
-
# it a wrong backend could pass by scheduling luck — the failure
|
|
228
|
+
# it a wrong backend could pass by scheduling luck — the failure relies on.
|
|
208
229
|
RSpec.shared_examples "an Insika store safe for N workers" do
|
|
209
230
|
# Starts N threads, each holding its OWN connection to the same backend,
|
|
210
231
|
# releases them together, and returns each block's value. Connections are
|