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,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Fixed-window spend counters for BUDGETS (WS2): the accounting layer the
|
|
5
|
+
# edge middleware rolls against. One scope, cells keyed
|
|
6
|
+
# "tenant:agent:window:calendar-bucket":
|
|
7
|
+
#
|
|
8
|
+
# · DAILY — the UTC calendar day (epoch/86400 IS midnight-aligned).
|
|
9
|
+
# · MONTHLY — (year * 12 + month) of the UTC calendar month: a budget month
|
|
10
|
+
# is the CALENDAR month, however many days long it is (a fixed
|
|
11
|
+
# N-day window drifts its bucket start across month lengths).
|
|
12
|
+
# UTC like the daily, so both rollovers agree on any host.
|
|
13
|
+
#
|
|
14
|
+
# Built on the UsageLedger vocabulary (tenant/agent instead of kind/id) but
|
|
15
|
+
# on the store directly, with the increment riding `@store.transaction` — the
|
|
16
|
+
# exact read-modify-write discipline WS2's enforcement will build on. Two
|
|
17
|
+
# processes (or two SQLite handles) racing the same cell serialize on
|
|
18
|
+
# BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
|
|
19
|
+
# WS2; this file is only correct accounting.
|
|
20
|
+
#
|
|
21
|
+
# Growth is bounded like UsageLedger: each `add` deletes the (id)'s previous
|
|
22
|
+
# day AND previous month cell, so an active scope holds at most 4 keys and an
|
|
23
|
+
# idle one converges to 2.
|
|
24
|
+
class BudgetLedger
|
|
25
|
+
SCOPE = "budget_counters"
|
|
26
|
+
ALERT_SCOPE = "budget_alerts"
|
|
27
|
+
DAY = 86_400
|
|
28
|
+
|
|
29
|
+
def initialize(store:)
|
|
30
|
+
@store = store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Adds `by` across both windows; -> { daily:, monthly: } the NEW totals
|
|
34
|
+
# for (tenant, agent). Atomic per call: one transaction, both bumps.
|
|
35
|
+
def add(tenant:, agent:, by:, now: Time.now)
|
|
36
|
+
id = cell_id(tenant, agent)
|
|
37
|
+
@store.transaction do
|
|
38
|
+
daily = bump(id, daily_bucket(now), by)
|
|
39
|
+
monthly = bump(id, month_bucket(now), by)
|
|
40
|
+
@store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell
|
|
41
|
+
@store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell
|
|
42
|
+
{ daily: daily, monthly: monthly }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# -> { daily:, monthly: } current totals for (tenant, agent). Purely
|
|
47
|
+
# read; an expired window reads as 0 (rolls over at the boundary).
|
|
48
|
+
def current(tenant:, agent:, now: Time.now)
|
|
49
|
+
id = cell_id(tenant, agent)
|
|
50
|
+
{ daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i,
|
|
51
|
+
monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Seconds until the window's bucket rolls over (the retry_after the
|
|
55
|
+
# enforcement quotes when a hard budget refuses a turn). Both windows are
|
|
56
|
+
# UTC-aligned (the daily via the epoch, the monthly via UTC components) so a
|
|
57
|
+
# non-UTC host never quotes a negative or local-midnight reset.
|
|
58
|
+
def reset_in(window, now: Time.now)
|
|
59
|
+
case window
|
|
60
|
+
when :daily then DAY - (now.to_i % DAY)
|
|
61
|
+
when :monthly then (next_utc_month_start(now) - now).to_i
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "1× per window" alert markers (the soft enforcement's event): a flag per
|
|
66
|
+
# (id, window, level, bucket) so a budget that stays over the threshold
|
|
67
|
+
# cannot spam one event per turn. `level:` separates DISTINCT triggers in
|
|
68
|
+
# the same window (WS2): the `alert_at` crossing and the real soft-cap
|
|
69
|
+
# crossing each warn once — the cap event must not be swallowed by the
|
|
70
|
+
# 80% marker having fired earlier. Marked/read in the same transaction
|
|
71
|
+
# discipline. -> bool: had the window already been marked?
|
|
72
|
+
def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
73
|
+
id = cell_id(tenant, agent)
|
|
74
|
+
flag = alert_key(id, window, now, level)
|
|
75
|
+
@store.transaction do
|
|
76
|
+
# `next`, NOT `return`: a non-local return from inside the block skips
|
|
77
|
+
# the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn
|
|
78
|
+
# over a threshold then locks the whole backend (WS2).
|
|
79
|
+
next true unless @store.get(ALERT_SCOPE, flag).nil?
|
|
80
|
+
|
|
81
|
+
@store.set(ALERT_SCOPE, flag, 1)
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def alerted?(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
87
|
+
!@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# No tenant (single_tenant default) is a LITERAL "platform" cell, never a
|
|
93
|
+
# null-key collision with some other scope.
|
|
94
|
+
def cell_id(tenant, agent)
|
|
95
|
+
[tenant || "platform", agent].join(":")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def bump(id, bucket, by)
|
|
99
|
+
total = @store.get(SCOPE, key(id, bucket)).to_i + by
|
|
100
|
+
@store.set(SCOPE, key(id, bucket), total)
|
|
101
|
+
total
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# the UTC calendar day's start (epoch is aligned to midnight UTC).
|
|
105
|
+
def daily_bucket(now)
|
|
106
|
+
(now.to_i / DAY) * DAY
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# the UTC calendar month as one integer (2026-08 -> 24296). UTC, not local:
|
|
110
|
+
# the month boundary must agree with the daily epoch-day boundary on a
|
|
111
|
+
# non-UTC host (WS2), or the cap resets at a different moment than the day.
|
|
112
|
+
def month_bucket(now)
|
|
113
|
+
u = now.utc
|
|
114
|
+
u.year * 12 + u.month
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Midnight UTC of the 1st of the window's NEXT month — December-safe
|
|
118
|
+
# (Time.utc(y, 13, 1) raises; y+1/1 is the calendar answer).
|
|
119
|
+
def next_utc_month_start(now)
|
|
120
|
+
u = now.utc
|
|
121
|
+
u.month == 12 ? Time.utc(u.year + 1, 1, 1) : Time.utc(u.year, u.month + 1, 1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def key(id, bucket)
|
|
125
|
+
"#{id}:#{bucket}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One alert flag per (id, window, level, calendar bucket): daily cells are
|
|
129
|
+
# keyed by day, monthly by (year*12+month) — a flag dies with its window.
|
|
130
|
+
def alert_key(id, window, now, level = nil)
|
|
131
|
+
bucket = window == :monthly ? month_bucket(now) : daily_bucket(now)
|
|
132
|
+
level ? "#{id}:#{window}:#{level}:#{bucket}" : "#{id}:#{window}:#{bucket}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Capability
|
|
7
|
+
# Thin decorator: swaps only the `name` exposed to the model for the
|
|
8
|
+
# STABLE capability name (e.g. "browse"), regardless of which concrete impl
|
|
9
|
+
# (`impl_name`, e.g. "puppeteer_browser") resolution chose.
|
|
10
|
+
# `execute`/`parameters`/`description`/`call` keep delegating to the impl via
|
|
11
|
+
# SimpleDelegator — nothing reimplemented, same spirit as `ToolEnvelope`.
|
|
12
|
+
#
|
|
13
|
+
# Wrapping order in run_pipeline: impl -> ResolvedTool ->
|
|
14
|
+
# ToolEnvelope. The Envelope, from the outside, sees the already-renamed call (the
|
|
15
|
+
# model calls `browse`); for side_effect?/approval it needs the REAL `impl_name`
|
|
16
|
+
# (it's the tool_registry that knows whether "puppeteer_browser" is a side-effect, not
|
|
17
|
+
# "browse") — that's why `impl_name` is exposed here. This is consumed by the
|
|
18
|
+
# Executor (ToolEnvelope).
|
|
19
|
+
class ResolvedTool < SimpleDelegator
|
|
20
|
+
def initialize(impl, capability_name:, impl_name:)
|
|
21
|
+
super(impl)
|
|
22
|
+
@capability_name = capability_name.to_s
|
|
23
|
+
@impl_name = impl_name.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# STABLE name exposed to the model — shadows the impl's `name`.
|
|
27
|
+
def name = @capability_name
|
|
28
|
+
|
|
29
|
+
# Concrete name behind resolution — for side_effect?/approval in the
|
|
30
|
+
# ToolEnvelope, NEVER exposed to the model.
|
|
31
|
+
def impl_name = @impl_name
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Intent→implementation resolution. Pure INDIRECTION: does NOT inherit
|
|
5
|
+
# from `Registry` (which holds executables) — it holds `Provider`s (resolution
|
|
6
|
+
# metadata) and returns the `impl_name` that ANOTHER registry instantiates.
|
|
7
|
+
# Immutable post-boot by construction (only boot/loader registers), like `Registry`.
|
|
8
|
+
class CapabilityRegistry
|
|
9
|
+
Provider = Data.define(:capability, :impl_name, :kind, :plugin, :priority, :available)
|
|
10
|
+
# kind: :tool | :workflow
|
|
11
|
+
# priority: Integer | nil (nil = lowest; inherits plugin precedence)
|
|
12
|
+
# available: callable -> bool (default -> { true }; never nil in a registered Provider)
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
# capability(Symbol) -> [Provider], na ordem de registro (proxy de announce)
|
|
16
|
+
@providers = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Unlike `Registry#register`, there is NO "first wins": registering the
|
|
20
|
+
# same capability more than once is the normal case (providers competing) —
|
|
21
|
+
# dedup/tie-breaking happens in `resolve`, not here.
|
|
22
|
+
def register(capability, impl_name:, kind:, plugin: nil, priority: nil, available: nil)
|
|
23
|
+
unless %i[tool workflow].include?(kind)
|
|
24
|
+
raise ArgumentError, "invalid kind: #{kind.inspect} (use :tool or :workflow)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if kind == :workflow
|
|
28
|
+
warn "[capability_registry] '#{capability}' registered with kind: :workflow — " \
|
|
29
|
+
"exposure to the agent deferred (L5)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@providers[capability.to_sym] << Provider.new(
|
|
33
|
+
capability: capability.to_sym, impl_name: impl_name.to_s, kind: kind,
|
|
34
|
+
plugin: plugin&.to_s, priority: priority, available: available || -> { true }
|
|
35
|
+
)
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def providers(capability) = @providers[capability.to_sym].dup
|
|
40
|
+
|
|
41
|
+
def capabilities = @providers.keys
|
|
42
|
+
|
|
43
|
+
# Loader rollback, symmetric to `Registry#deregister_plugin`. Removes
|
|
44
|
+
# only the plugin's Providers; capabilities with no remaining provider drop
|
|
45
|
+
# from `capabilities` (clears the key so the Hash.new-with-block won't recreate it empty).
|
|
46
|
+
def deregister_plugin(plugin_id)
|
|
47
|
+
@providers.each_value { |list| list.delete_if { |p| p.plugin == plugin_id.to_s } }
|
|
48
|
+
@providers.delete_if { |_cap, list| list.empty? }
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -> chosen Provider | raise CapabilityUnavailable | raise CapabilityAmbiguous.
|
|
53
|
+
# PURE and deterministic: same input → same choice or same error. No
|
|
54
|
+
# IO beyond the Provider's own `available.call`. Emits `:capability_resolved`
|
|
55
|
+
# when `event_stream:` is present (audit).
|
|
56
|
+
def resolve(capability, profile:, context: {}, event_stream: nil)
|
|
57
|
+
candidates = providers(capability)
|
|
58
|
+
candidates = candidates.select { |p| p.available.call }
|
|
59
|
+
candidates = apply_deny(candidates, profile)
|
|
60
|
+
|
|
61
|
+
raise CapabilityUnavailable.new(capability: capability) if candidates.empty?
|
|
62
|
+
|
|
63
|
+
chosen = pick_top(candidates, capability)
|
|
64
|
+
event_stream&.emit(Insika::Event.new(
|
|
65
|
+
type: :capability_resolved,
|
|
66
|
+
data: {
|
|
67
|
+
capability: capability.to_sym,
|
|
68
|
+
chosen: chosen.impl_name,
|
|
69
|
+
candidates: candidates.map do |p|
|
|
70
|
+
{ impl_name: p.impl_name, plugin: p.plugin, priority: p.priority }
|
|
71
|
+
end
|
|
72
|
+
}
|
|
73
|
+
))
|
|
74
|
+
chosen
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Resolution applies ONLY `tools_deny` over `impl_name` (deny ALWAYS wins) — it does
|
|
80
|
+
# NOT apply `tools_allow`: the grant to use the capability is
|
|
81
|
+
# listing it in `profile.capabilities`, checked by the Executor BEFORE
|
|
82
|
+
# calling `resolve`. Reusing `tools_allow` would filter out a provider for
|
|
83
|
+
# an agent that lists only the capability (not the raw impl). Per-agent provider
|
|
84
|
+
# pinning (`capability_providers`) is future work.
|
|
85
|
+
def apply_deny(candidates, profile)
|
|
86
|
+
deny = Array(profile.tools_deny).map(&:to_s)
|
|
87
|
+
candidates.reject { |p| deny.include?(p.impl_name) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `priority` desc primary, `nil` as the LOWEST possible (below
|
|
91
|
+
# any Integer, including negative — do not normalize to 0, which would collide
|
|
92
|
+
# with an explicit `priority: 0`). Tie-break by plugin precedence (registration
|
|
93
|
+
# order, announce proxy): different plugins always
|
|
94
|
+
# break the tie; same plugin (nil included) tied = CapabilityAmbiguous.
|
|
95
|
+
def pick_top(candidates, capability)
|
|
96
|
+
indexed = providers(capability).each_with_index.to_h { |p, i| [p, i] }
|
|
97
|
+
|
|
98
|
+
rank = ->(p) { p.priority.nil? ? [0, 0] : [1, p.priority] }
|
|
99
|
+
top_rank = candidates.map(&rank).max
|
|
100
|
+
top = candidates.select { |p| rank.call(p) == top_rank }
|
|
101
|
+
|
|
102
|
+
return top.first if top.size == 1
|
|
103
|
+
|
|
104
|
+
groups = top.group_by(&:plugin).values
|
|
105
|
+
if groups.any? { |g| g.size > 1 }
|
|
106
|
+
raise CapabilityAmbiguous.new(capability: capability, candidates: top)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
groups.min_by { |g| indexed[g.first] }.first
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Hands a finished turn's answer to a Shape B channel. The turn
|
|
7
|
+
# ended; the recipient is not on any connection; the reply has to travel out of
|
|
8
|
+
# band and survive a crash on the way. Three moves, in this order, and the order
|
|
9
|
+
# is the whole design:
|
|
10
|
+
#
|
|
11
|
+
# 1. RECORD at the turn's terminal (durable, `pending`).
|
|
12
|
+
# 2. CLAIM before the HTTP call (`pending -> delivering`, atomic). A crash
|
|
13
|
+
# between the claim and the POST loses that delivery; it does not duplicate
|
|
14
|
+
# it. At-most-once, stated rather than papered over — the same honest scope
|
|
15
|
+
# the async-delegation path already has.
|
|
16
|
+
# 3. RETRY, bounded and explicit. Unlike a delegation, the recipient is a third
|
|
17
|
+
# party with outages, so "keep trying" is a real requirement and "keep trying
|
|
18
|
+
# forever" is a real outage of ours.
|
|
19
|
+
#
|
|
20
|
+
# It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
|
|
21
|
+
# grows one, the thing to do is take a real queue, not to finish building this.
|
|
22
|
+
class ChannelDelivery
|
|
23
|
+
MAX_ATTEMPTS = 3
|
|
24
|
+
# Waits BETWEEN attempts, so attempt 1 is immediate. Short on purpose: a
|
|
25
|
+
# customer waiting on WhatsApp is the deadline, not the consumer's SLA.
|
|
26
|
+
BACKOFF_SECONDS = [1, 5].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(channels:, outbox:, session_store:, event_stream: nil,
|
|
29
|
+
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil)
|
|
30
|
+
@channels = channels
|
|
31
|
+
@outbox = outbox
|
|
32
|
+
@session_store = session_store
|
|
33
|
+
@event_stream = event_stream
|
|
34
|
+
@max_attempts = max_attempts
|
|
35
|
+
@backoff = Array(backoff)
|
|
36
|
+
@sleeper = sleeper || method(:default_sleep)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The turn committed an answer. -> the Delivery to dispatch, or nil when there
|
|
40
|
+
# is nothing to deliver, which is the common case and must stay cheap:
|
|
41
|
+
# · the turn did not come in through a channel,
|
|
42
|
+
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
|
+
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
|
+
# half a sentence was never an answer),
|
|
45
|
+
# · or we do not know who to send it to.
|
|
46
|
+
def record(task:, channel_id:, content:)
|
|
47
|
+
return nil if content.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
channel = @channels&.find(channel_id)
|
|
50
|
+
return nil unless channel.respond_to?(:deliver)
|
|
51
|
+
|
|
52
|
+
to = recipient(channel, task.session_id)
|
|
53
|
+
return nil if to.nil? || to.empty?
|
|
54
|
+
|
|
55
|
+
@outbox.create(
|
|
56
|
+
channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
|
|
57
|
+
payload: { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
|
|
58
|
+
"content" => content.to_s }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Claim + POST + bounded retry. Safe to call twice: the second caller loses the
|
|
63
|
+
# claim and returns without touching the recipient.
|
|
64
|
+
def deliver(id)
|
|
65
|
+
return false unless @outbox.claim(id)
|
|
66
|
+
|
|
67
|
+
delivery = @outbox.find(id)
|
|
68
|
+
channel = @channels&.find(delivery&.channel)
|
|
69
|
+
# The channel vanished between the record and the dispatch (a plugin was
|
|
70
|
+
# disabled, the deployment was reconfigured). Nothing can send this; leave it
|
|
71
|
+
# terminal so the boot sweep does not spin on it forever.
|
|
72
|
+
if channel.nil? || !channel.respond_to?(:deliver)
|
|
73
|
+
return finish(@outbox.mark_failed(id, error: "channel '#{delivery&.channel}' is not registered"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
attempt(delivery, channel)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Boot: re-drive what a previous process recorded and never claimed. Records
|
|
80
|
+
# left `delivering` are deliberately NOT swept — that process may have POSTed
|
|
81
|
+
# before it died, and replaying is the duplicate the claim exists to prevent.
|
|
82
|
+
# -> { dispatched: [ids] }
|
|
83
|
+
def sweep
|
|
84
|
+
dispatched = @outbox.pending.map do |delivery|
|
|
85
|
+
deliver(delivery.id)
|
|
86
|
+
delivery.id
|
|
87
|
+
end
|
|
88
|
+
{ dispatched: dispatched }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def attempt(delivery, channel)
|
|
94
|
+
last_error = nil
|
|
95
|
+
|
|
96
|
+
@max_attempts.times do |i|
|
|
97
|
+
@sleeper.call(@backoff[i - 1]) if i.positive? && @backoff[i - 1]
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
status = channel.deliver(delivery.payload, to: delivery.to, delivery_id: delivery.id)
|
|
101
|
+
return finish(@outbox.mark_delivered(delivery.id)) if (200..299).cover?(status.to_i)
|
|
102
|
+
|
|
103
|
+
last_error = "HTTP #{status}"
|
|
104
|
+
rescue Insika::DeliveryError => e
|
|
105
|
+
last_error = e.message
|
|
106
|
+
end
|
|
107
|
+
@outbox.record_attempt(delivery.id, error: last_error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
finish(@outbox.mark_failed(delivery.id, error: last_error))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The consumer's own key for this conversation. Written into the session's vars
|
|
114
|
+
# when the channel minted it; the channel's own id parser is the fallback
|
|
115
|
+
# for a session created before those vars existed.
|
|
116
|
+
def recipient(channel, session_id)
|
|
117
|
+
session = session_id && @session_store.find(session_id)
|
|
118
|
+
vars = session&.vars || {}
|
|
119
|
+
from_vars = vars["external_id"] || vars[:external_id]
|
|
120
|
+
return from_vars.to_s if Insika::Coercion.presence(from_vars)
|
|
121
|
+
|
|
122
|
+
channel.respond_to?(:external_id_from) ? channel.external_id_from(session_id) : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def finish(delivery)
|
|
126
|
+
emit(delivery)
|
|
127
|
+
delivery.status == :delivered
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def emit(delivery)
|
|
131
|
+
return unless @event_stream
|
|
132
|
+
|
|
133
|
+
data = { channel: delivery.channel, outbox_id: delivery.id,
|
|
134
|
+
status: delivery.status.to_s, attempts: delivery.attempts,
|
|
135
|
+
error: delivery.last_error }
|
|
136
|
+
meta = { task_id: delivery.task_id, session_id: delivery.session_id,
|
|
137
|
+
at: Time.now.utc.iso8601 }
|
|
138
|
+
# :delivery_failed is the ALERT face of a failed delivery (WS6) — emitted
|
|
139
|
+
# alongside :channel_delivered so the delivery audit stream is unchanged.
|
|
140
|
+
@event_stream.emit(Insika::Event.new(type: :channel_delivered, data: data, meta: meta))
|
|
141
|
+
if delivery.status == :failed
|
|
142
|
+
@event_stream.emit(Insika::Event.new(type: :delivery_failed, data: data, meta: meta))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Async when there is a reactor (production: the retry must not block the
|
|
147
|
+
# worker), plain sleep otherwise (boot sweep before the reactor, specs).
|
|
148
|
+
def default_sleep(seconds)
|
|
149
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
150
|
+
task ? task.sleep(seconds) : sleep(seconds)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# The channels this deployment speaks, by id. A `Registry` like
|
|
5
|
+
# tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
|
|
6
|
+
# half-registered plugin back exactly as it does for a tool.
|
|
7
|
+
#
|
|
8
|
+
# The id is the URL segment (`/channels/relay/events`), which is why lookup here
|
|
9
|
+
# is a `find` and not a `resolve`: an unknown segment is a 404, not an exception
|
|
10
|
+
# the transport has to rescue.
|
|
11
|
+
class ChannelRegistry < Registry
|
|
12
|
+
# -> the channel instance | nil (unknown id). A blank id never matches.
|
|
13
|
+
def find(id)
|
|
14
|
+
name = id.to_s
|
|
15
|
+
return nil if name.empty?
|
|
16
|
+
|
|
17
|
+
resolve(name)
|
|
18
|
+
rescue Insika::NotFoundError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Does this channel deliver out of band (Shape B)? A Shape A channel answers on
|
|
23
|
+
# the request's own stream and has no `deliver`, so nothing is ever written to
|
|
24
|
+
# the outbox for it. Duck-typed, like every other seam in the engine.
|
|
25
|
+
def deliverable?(id)
|
|
26
|
+
channel = find(id)
|
|
27
|
+
!channel.nil? && channel.respond_to?(:deliver)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Insika
|
|
9
|
+
module Channels
|
|
10
|
+
# The channel for an adopter who ALREADY owns a messaging integration
|
|
11
|
+
# A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
|
|
12
|
+
# engine for the TURN, not for the platform. Two routes and an envelope —
|
|
13
|
+
#
|
|
14
|
+
# consumer --POST /channels/relay/events--> engine acked now, never the reply
|
|
15
|
+
# consumer <--POST <deliver_url>---------- engine the reply, when there is one
|
|
16
|
+
#
|
|
17
|
+
# — and everything platform-shaped stays theirs: the 24-hour window, template
|
|
18
|
+
# approval, media, read receipts, and how markdown becomes WhatsApp formatting
|
|
19
|
+
# That is the promise, not the limitation: an integration someone has
|
|
20
|
+
# already tuned for years does not have to move for them to adopt the engine.
|
|
21
|
+
# A relay that starts growing template logic has stopped being a relay.
|
|
22
|
+
#
|
|
23
|
+
# It is also the cheapest possible Shape B, which is why it is built first:
|
|
24
|
+
# both ends are ours, so there is no third-party signature scheme and no
|
|
25
|
+
# rendering to get wrong at the same time as the durability. What it DOES
|
|
26
|
+
# exercise — the outbox, the claim, bounded retry, inbound dedup — is what
|
|
27
|
+
# Slack and native WhatsApp inherit untouched.
|
|
28
|
+
#
|
|
29
|
+
# R1/R2 hold: this object translates and authenticates, and does nothing else.
|
|
30
|
+
# No Executor, no store, no RubyLLM; it may refuse a request, never grant a
|
|
31
|
+
# capability.
|
|
32
|
+
class Relay
|
|
33
|
+
DEFAULT_ID = "relay"
|
|
34
|
+
DEFAULT_TIMEOUT = 10
|
|
35
|
+
|
|
36
|
+
attr_reader :id
|
|
37
|
+
|
|
38
|
+
# The bundled relay as an operator configures it: three env vars, of which the
|
|
39
|
+
# token is the SWITCH — no token, no channel, so there is no way to end up with
|
|
40
|
+
# this route mounted and open. -> Relay | nil.
|
|
41
|
+
#
|
|
42
|
+
# Shared by every composition root on purpose: the DSL front door has to reach
|
|
43
|
+
# the same feature as `config.ru`, or the docs are true of only one of them.
|
|
44
|
+
def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
|
|
45
|
+
token = Insika::EnvSchema.read("INSIKA_RELAY_TOKEN", env)
|
|
46
|
+
return nil unless Insika::EnvSchema.present?(token)
|
|
47
|
+
|
|
48
|
+
new(inbound_token: token,
|
|
49
|
+
deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
|
|
50
|
+
deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
|
|
51
|
+
http: http, allow_http: allow_http, allow_private: allow_private)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# inbound_token: shared secret the consumer sends us (Bearer). Blank ->
|
|
55
|
+
# the channel answers :disabled to every request, fail-closed
|
|
56
|
+
# by construction rather than open by omission.
|
|
57
|
+
# deliver_url: where the reply goes. Blank -> nothing is ever delivered
|
|
58
|
+
# (the channel still accepts inbound; the outbox records the
|
|
59
|
+
# reply and the delivery fails loudly instead of silently).
|
|
60
|
+
# deliver_token: Bearer we send THEM. Optional: a consumer on a private
|
|
61
|
+
# network may authenticate us another way.
|
|
62
|
+
def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
|
|
63
|
+
id: DEFAULT_ID, allow_http: false, allow_private: false,
|
|
64
|
+
timeout: DEFAULT_TIMEOUT)
|
|
65
|
+
@id = id.to_s
|
|
66
|
+
@inbound_token = inbound_token.to_s
|
|
67
|
+
@deliver_url = deliver_url.to_s
|
|
68
|
+
@deliver_token = deliver_token.to_s
|
|
69
|
+
@http = http || Insika::HttpClient.new
|
|
70
|
+
@allow_http = allow_http
|
|
71
|
+
@allow_private = allow_private
|
|
72
|
+
@timeout = timeout
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the
|
|
76
|
+
# RFC sketched one): a status code is the transport's vocabulary, and keeping
|
|
77
|
+
# it out of here is what lets this class be tested without Rack and read
|
|
78
|
+
# without knowing HTTP.
|
|
79
|
+
def authenticate(req)
|
|
80
|
+
return :disabled if @inbound_token.empty?
|
|
81
|
+
|
|
82
|
+
provided = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer (.+)\z/, 1]
|
|
83
|
+
return :unauthorized if provided.nil?
|
|
84
|
+
|
|
85
|
+
secure_compare(@inbound_token, provided) ? :ok : :unauthorized
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Inbound envelope -> the fields the mount turns into a `:send_message`.
|
|
89
|
+
# STRING keys in, because the consumer's `vars` are arbitrary data keys.
|
|
90
|
+
#
|
|
91
|
+
# { "agent": "support", "external_id": "5511999998888",
|
|
92
|
+
# "event_id": "wamid.HBg…", "message": "queria saber do pedido",
|
|
93
|
+
# "vars": { … } }
|
|
94
|
+
def parse(_req, body:)
|
|
95
|
+
body = body.is_a?(Hash) ? body : {}
|
|
96
|
+
agent = string(body["agent"])
|
|
97
|
+
external_id = string(body["external_id"])
|
|
98
|
+
message = string(body["message"])
|
|
99
|
+
|
|
100
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
101
|
+
raise Insika::ValidationError, "external_id is required" if external_id.empty?
|
|
102
|
+
raise Insika::ValidationError, "message is required" if message.strip.empty?
|
|
103
|
+
|
|
104
|
+
vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
|
|
105
|
+
{ agent: agent, external_id: external_id, message: message,
|
|
106
|
+
event_id: presence(body["event_id"]), vars: vars }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# the engine namespaces the platform's conversation key, so a
|
|
110
|
+
# Slack channel id and a phone number can never collide, an operator can see
|
|
111
|
+
# where a conversation came from, and an id minted for one channel cannot be
|
|
112
|
+
# used to read another's session.
|
|
113
|
+
def session_id_for(external_id) = "#{@id}:#{external_id}"
|
|
114
|
+
|
|
115
|
+
# The reverse: what the consumer called this conversation. Reads off the
|
|
116
|
+
# session id so a delivery needs no extra state.
|
|
117
|
+
def external_id_from(session_id)
|
|
118
|
+
s = session_id.to_s
|
|
119
|
+
s.start_with?("#{@id}:") ? s.delete_prefix("#{@id}:") : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hands ONE reply to the consumer's callback. -> the HTTP status (the
|
|
123
|
+
# dispatcher decides what 2xx means); raises DeliveryError when the request
|
|
124
|
+
# could not be made at all.
|
|
125
|
+
#
|
|
126
|
+
# `X-Insika-Delivery` is the outbox id: a stable idempotency key, so a
|
|
127
|
+
# consumer that receives the same delivery twice (we retried after a timeout
|
|
128
|
+
# that actually landed) can drop the second one.
|
|
129
|
+
def deliver(payload, to:, delivery_id: nil)
|
|
130
|
+
raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?
|
|
131
|
+
|
|
132
|
+
if (reason = egress_violation)
|
|
133
|
+
raise Insika::DeliveryError, "egress blocked for deliver_url: #{reason}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
response = @http.request(method: :post, url: @deliver_url, timeout: @timeout,
|
|
137
|
+
headers: headers(delivery_id),
|
|
138
|
+
body: JSON.generate(payload.merge("external_id" => to.to_s)))
|
|
139
|
+
response[:status].to_i
|
|
140
|
+
rescue Insika::DeliveryError
|
|
141
|
+
raise
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
raise Insika::DeliveryError, "#{e.class}: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Resolved on EVERY call, not once at boot: a hostname that answered a public
|
|
149
|
+
# address yesterday can answer 169.254.169.254 today, and this POST carries
|
|
150
|
+
# the customer's conversation.
|
|
151
|
+
def egress_violation
|
|
152
|
+
Insika::EgressGuard.violation(@deliver_url, allow_http: @allow_http,
|
|
153
|
+
allow_private: @allow_private,
|
|
154
|
+
host_allowlist: [URI.parse(@deliver_url).host].compact)
|
|
155
|
+
rescue URI::InvalidURIError
|
|
156
|
+
"invalid URL"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def headers(delivery_id)
|
|
160
|
+
h = { "content-type" => "application/json" }
|
|
161
|
+
h["authorization"] = "Bearer #{@deliver_token}" unless @deliver_token.empty?
|
|
162
|
+
h["x-insika-delivery"] = delivery_id.to_s if delivery_id
|
|
163
|
+
h
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Constant time, and length-safe: comparing the digests means an attacker
|
|
167
|
+
# learns nothing from how long the check took, not even the token's length.
|
|
168
|
+
# OpenSSL rather than Rack::Utils so the engine's lib/ keeps no web framework
|
|
169
|
+
# in its load path.
|
|
170
|
+
def secure_compare(a, b)
|
|
171
|
+
OpenSSL.fixed_length_secure_compare(Digest::SHA256.digest(a), Digest::SHA256.digest(b))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def string(value) = value.to_s
|
|
175
|
+
def presence(value) = Insika::Coercion.presence(value)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|