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
data/lib/insika/server/app.rb
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "rack"
|
|
5
5
|
require "async"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
require_relative "sse_body"
|
|
7
|
-
require_relative "
|
|
8
|
+
require_relative "tenant_auth" # WS1: Bearer -> { role:, tenant_id: } (single/multi-tenant)
|
|
8
9
|
require_relative "a2a/app" # A2A edge adapter (pulls protocol/errors/message/projection/card)
|
|
9
10
|
require_relative "responses" # OpenAI Responses adapter (/v1/responses) — drop-in for the OpenClaw gateway
|
|
10
11
|
|
|
@@ -32,7 +33,7 @@ module Insika
|
|
|
32
33
|
TERMINAL_EVENTS = %i[task_completed task_failed task_cancelled].freeze
|
|
33
34
|
private_constant :TERMINAL_EVENTS
|
|
34
35
|
|
|
35
|
-
#
|
|
36
|
+
# the `/v1` contract, versioned by date. A caller PINS behaviour
|
|
36
37
|
# with `Insika-Version: YYYY-MM-DD` so a future breaking change does not move
|
|
37
38
|
# silently underneath it; absent header = today's (only) version. Only one
|
|
38
39
|
# entry exists so far — the day a second one is added, the routes that
|
|
@@ -41,13 +42,21 @@ module Insika
|
|
|
41
42
|
KNOWN_VERSIONS = ["2026-08-08"].freeze
|
|
42
43
|
private_constant :KNOWN_VERSIONS
|
|
43
44
|
|
|
44
|
-
#
|
|
45
|
+
# the 500 envelope. A 500 is by definition unexpected — the client
|
|
46
|
+
# cannot fix the request, so the contract is "you may retry, wait this
|
|
47
|
+
# long, and quote this ref when you report it". The ref only means
|
|
48
|
+
# anything because the same line goes to the server log (see #internal_error_response).
|
|
49
|
+
RETRY_AFTER_SECONDS = 1
|
|
50
|
+
private_constant :RETRY_AFTER_SECONDS
|
|
51
|
+
|
|
52
|
+
# The operator control UI now lives in the Studio; server/ is a
|
|
45
53
|
# pure transport surface (/v1, /a2a). The constitutional rule holds: server/
|
|
46
54
|
# only READS stores and never imports the Executor, store writes, or RubyLLM.
|
|
47
55
|
def initialize(command_bus:, event_stream:, session_store:, task_store:,
|
|
48
56
|
config:, pending_action_store: nil, a2a: nil, provisioner: nil,
|
|
49
57
|
workflow_registry: nil, onboarding: nil, profiles: nil,
|
|
50
|
-
channels: nil
|
|
58
|
+
channels: nil, logger: nil, token_store: nil, outcome_store: nil,
|
|
59
|
+
executor: nil, db_path: nil)
|
|
51
60
|
@command_bus = command_bus
|
|
52
61
|
@event_stream = event_stream
|
|
53
62
|
@session_store = session_store
|
|
@@ -56,26 +65,45 @@ module Insika
|
|
|
56
65
|
@pending_action_store = pending_action_store # read for GET /v1/tasks/:id
|
|
57
66
|
@a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
|
|
58
67
|
@provisioner = provisioner # PackImporter. nil = provisioning not exposed.
|
|
59
|
-
#
|
|
68
|
+
# WS1 multi-tenant credentials. nil = single_tenant mode (the classic
|
|
69
|
+
# single operator credential, gateway_token). Present = tokens resolve
|
|
70
|
+
# from the store (per-tenant + operator), gateway_token still resolves
|
|
71
|
+
# as operator (an existing deployment switching modes keeps its token).
|
|
72
|
+
@token_store = token_store
|
|
73
|
+
# "single_tenant" (default, parity) | "multi_tenant".
|
|
74
|
+
@tenancy = config.fetch(:tenancy, "single_tenant")
|
|
75
|
+
# READ-ONLY registry, injected only where workflows are
|
|
60
76
|
# exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
|
|
61
77
|
# deployment does not expose workflows). Reading a catalog is a READ, like a
|
|
62
78
|
# store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
|
|
63
79
|
@workflow_registry = workflow_registry
|
|
64
|
-
#
|
|
80
|
+
# LLM-first onboarding surface (start.md + models.json +
|
|
65
81
|
# docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
|
|
66
82
|
# is that the developer's coding agent can fetch it), and READ-ONLY, so the
|
|
67
83
|
# constitutional rule holds. nil = routes not exposed (parity — the production
|
|
68
84
|
# deployment opts in). Reading files/masked stores is a READ, like a store read.
|
|
69
85
|
@onboarding = onboarding
|
|
70
|
-
#
|
|
86
|
+
# READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
|
|
71
87
|
# what an agent has — the eval is a client and cannot read a store. Same
|
|
72
88
|
# constitutional footing as the workflow registry: reading a catalog is a
|
|
73
89
|
# READ. nil = the route 404s (parity).
|
|
74
90
|
@profiles = profiles
|
|
75
|
-
#
|
|
91
|
+
# ONE generic route family for every channel, opt-in by
|
|
76
92
|
# injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
|
|
77
93
|
# The channel does the translating; this class keeps doing only transport.
|
|
78
94
|
@channels = channels
|
|
95
|
+
# WS7: business outcomes per conversation (POST /v1/outcomes — a Control
|
|
96
|
+
# command on the bus — and the tenant-scoped GET /v1/outcomes read).
|
|
97
|
+
# nil = the routes 404 (parity).
|
|
98
|
+
@outcome_store = outcome_store
|
|
99
|
+
# where a 500's error_ref goes to be FOUND. nil = silent (parity for
|
|
100
|
+
# embedders); the serving wirings pass $stdout. Class+message+backtrace
|
|
101
|
+
# only — the ref never travels with request payloads (secrets stay out).
|
|
102
|
+
@logger = logger
|
|
103
|
+
# GET /v1/vitals: process readings, operator-only, no store.
|
|
104
|
+
# Both optional — nil means the body simply omits those readings.
|
|
105
|
+
@executor = executor
|
|
106
|
+
@db_path = db_path
|
|
79
107
|
@heartbeat = config.fetch(:heartbeat, 15)
|
|
80
108
|
@sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
|
|
81
109
|
end
|
|
@@ -96,7 +124,7 @@ module Insika
|
|
|
96
124
|
rescue Async::TimeoutError => e
|
|
97
125
|
error_response(504, e) # synchronous control request exceeded the ceiling
|
|
98
126
|
rescue StandardError => e
|
|
99
|
-
|
|
127
|
+
internal_error_response(e)
|
|
100
128
|
end
|
|
101
129
|
|
|
102
130
|
private
|
|
@@ -114,8 +142,15 @@ module Insika
|
|
|
114
142
|
return version_error if version_error
|
|
115
143
|
end
|
|
116
144
|
|
|
117
|
-
|
|
145
|
+
gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
|
|
118
146
|
return gate if gate
|
|
147
|
+
# WS1: a TENANT principal is confined to its own runtime surfaces (chat
|
|
148
|
+
# + its own reads). Every authoring/provisioning surface stays
|
|
149
|
+
# operator-only — a tenant can never mint tokens, author tools or
|
|
150
|
+
# change platform config. single_tenant (no principal) is untouched.
|
|
151
|
+
if tenant_principal?(req) && !tenant_surface?(req.request_method, segments)
|
|
152
|
+
return auth_error(403, "operator surface")
|
|
153
|
+
end
|
|
119
154
|
|
|
120
155
|
case [req.request_method, segments]
|
|
121
156
|
in ["GET", ["up"]]
|
|
@@ -140,6 +175,10 @@ module Insika
|
|
|
140
175
|
handle_trigger_workflow(req, name)
|
|
141
176
|
in ["POST", ["v1", "responses"]]
|
|
142
177
|
handle_responses(req)
|
|
178
|
+
in ["POST", ["v1", "outcomes"]] if @outcome_store
|
|
179
|
+
handle_record_outcome(req)
|
|
180
|
+
in ["GET", ["v1", "outcomes"]] if @outcome_store
|
|
181
|
+
handle_list_outcomes(req)
|
|
143
182
|
in ["POST", ["v1", "tools", "manifest"]]
|
|
144
183
|
handle_import_tools(req)
|
|
145
184
|
in ["POST", ["v1", "mcp", name, "import"]]
|
|
@@ -151,13 +190,17 @@ module Insika
|
|
|
151
190
|
in ["GET", ["v1", "agents", id]] if @profiles
|
|
152
191
|
handle_read_agent(id)
|
|
153
192
|
in ["GET", ["v1", "sessions", id]]
|
|
154
|
-
handle_read_session(id)
|
|
193
|
+
handle_read_session(req, id)
|
|
155
194
|
in ["GET", ["v1", "tasks", id]]
|
|
156
|
-
handle_read_task(id)
|
|
195
|
+
handle_read_task(req, id)
|
|
157
196
|
in ["GET", ["v1", "events"]]
|
|
158
197
|
handle_events(req)
|
|
198
|
+
in ["GET", ["v1", "vitals"]]
|
|
199
|
+
handle_vitals
|
|
159
200
|
in ["POST", ["channels", id, "events"]] if @channels
|
|
160
201
|
handle_channel_event(req, id)
|
|
202
|
+
in ["POST", ["channels", id, "shadow-reply"]] if @channels
|
|
203
|
+
handle_channel_shadow_reply(req, id)
|
|
161
204
|
in ["POST", ["channels", id, "sessions"]] if @channels
|
|
162
205
|
handle_channel_session(req, id)
|
|
163
206
|
in ["POST", ["channels", id, "messages"]] if @channels
|
|
@@ -215,7 +258,7 @@ module Insika
|
|
|
215
258
|
# tomorrow is gated by default and publishing it is a deliberate edit here.
|
|
216
259
|
def channel_route?(method, segments)
|
|
217
260
|
case [method, segments]
|
|
218
|
-
in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
|
|
261
|
+
in ["POST", ["channels", _, "events" | "sessions" | "messages" | "shadow-reply"]] then true
|
|
219
262
|
in ["GET", ["channels", _, "asset", _]] then true
|
|
220
263
|
in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
|
|
221
264
|
else false
|
|
@@ -233,16 +276,62 @@ module Insika
|
|
|
233
276
|
# POST /v1/commands/:type — generic: every new Command is born with a
|
|
234
277
|
# transport. The control vs turn distinction is BY THE SHAPE of the result (the
|
|
235
278
|
# transport knows no semantics).
|
|
279
|
+
#
|
|
280
|
+
# The principal's tenant is stamped like on every other surface. It is
|
|
281
|
+
# nil for an operator (this route is operator-only — see TENANT_SURFACES),
|
|
282
|
+
# which is exactly why a tenant-scoped command such as `forget_customer`
|
|
283
|
+
# ALSO reads a `tenant` from its payload: over HTTP the operator names the
|
|
284
|
+
# tenant, because the credential does not carry one.
|
|
236
285
|
def handle_command(req, type)
|
|
237
|
-
command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http
|
|
286
|
+
command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http,
|
|
287
|
+
tenant: req_tenant(req))
|
|
238
288
|
command_response(dispatch_with_timeout(command))
|
|
239
289
|
end
|
|
240
290
|
|
|
291
|
+
# POST /v1/outcomes — the operator or the integration records a business
|
|
292
|
+
# outcome for a conversation (WS7). A Control command on the bus, tenant
|
|
293
|
+
# stamped from the principal (WS1) — additive, outside the response
|
|
294
|
+
# contract: the engine transports the outcome, never interprets it.
|
|
295
|
+
# 201 { outcome: { agent, outcome, value, session_id, at } }.
|
|
296
|
+
def handle_record_outcome(req)
|
|
297
|
+
command = Insika::Command.build(:record_outcome, parse_body(req), transport: :http,
|
|
298
|
+
tenant: req_tenant(req))
|
|
299
|
+
record = dispatch_with_timeout(command)
|
|
300
|
+
json_response(201, { outcome: { agent: record.agent, outcome: record.outcome,
|
|
301
|
+
value: record.value, session_id: record.session_id,
|
|
302
|
+
at: record.at } })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# GET /v1/outcomes[?agent=] — the Studio scorecard's data: the LAST
|
|
306
|
+
# outcome per agent (state cards) + the per-day series. Tenant-scoped
|
|
307
|
+
# (WS1): a tenant principal reads only its own outcomes.
|
|
308
|
+
def handle_list_outcomes(req)
|
|
309
|
+
tenant = req_tenant(req)
|
|
310
|
+
agent = req.GET["agent"]
|
|
311
|
+
latest = @outcome_store.latest_per_agent(tenant: tenant)
|
|
312
|
+
latest = { agent => latest[agent] }.compact if agent && !agent.empty?
|
|
313
|
+
series = @outcome_store.series(tenant: tenant, agent: agent)
|
|
314
|
+
json_response(200, { latest: latest, series: series })
|
|
315
|
+
end
|
|
316
|
+
|
|
241
317
|
# POST /v1/sessions — sugar for create_session; 201 {session}.
|
|
242
318
|
def handle_create_session(req)
|
|
243
319
|
body = parse_body(req)
|
|
244
|
-
|
|
245
|
-
|
|
320
|
+
tenant = req_tenant(req)
|
|
321
|
+
# WS1: a tenant's session must be born under its OWN "<tenant>:" namespace
|
|
322
|
+
# — the read path (GET /v1/sessions/:id) refuses anything else. Scope the
|
|
323
|
+
# caller's id the same way message_flow scopes a session_id; a tenant that
|
|
324
|
+
# passed none gets a namespaced uuid instead of an unprefixed one it could
|
|
325
|
+
# never read back.
|
|
326
|
+
if tenant
|
|
327
|
+
id = body[:id] || body["id"]
|
|
328
|
+
id = Insika::Coercion.blank?(id) ? scoped_session_id(tenant, SecureRandom.uuid)
|
|
329
|
+
: scoped_session_id(tenant, id)
|
|
330
|
+
body = body.merge(id: id)
|
|
331
|
+
end
|
|
332
|
+
command = Insika::Command.build(:create_session,
|
|
333
|
+
{ vars: body[:vars] || {}, id: body[:id] }.compact,
|
|
334
|
+
transport: :http, tenant: tenant)
|
|
246
335
|
session = dispatch_with_timeout(command)
|
|
247
336
|
json_response(201, { session: session.to_h })
|
|
248
337
|
end
|
|
@@ -251,13 +340,14 @@ module Insika
|
|
|
251
340
|
# "false" -> 200 JSON aggregated at the terminal event.
|
|
252
341
|
def handle_send_message(req)
|
|
253
342
|
stream = req.GET["stream"] != "false"
|
|
254
|
-
#
|
|
343
|
+
# only the aggregated-JSON form has room for the `merged`/`steered`
|
|
255
344
|
# verdict, so only it may join a message to another turn. Once the stream is open
|
|
256
345
|
# there is no way to tell the caller it does not own the reply.
|
|
257
|
-
message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json"
|
|
346
|
+
message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
|
|
347
|
+
tenant: req_tenant(req))
|
|
258
348
|
end
|
|
259
349
|
|
|
260
|
-
# GET /docs/:name.md — one public doc as raw markdown
|
|
350
|
+
# GET /docs/:name.md — one public doc as raw markdown. The
|
|
261
351
|
# slug is a KEY of the onboarding allowlist, so no filesystem traversal is
|
|
262
352
|
# possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
|
|
263
353
|
def handle_doc(file)
|
|
@@ -274,7 +364,7 @@ module Insika
|
|
|
274
364
|
Insika::Coercion.presence(@config[:public_url]) || req.base_url
|
|
275
365
|
end
|
|
276
366
|
|
|
277
|
-
# GET /v1/workflows — discovery
|
|
367
|
+
# GET /v1/workflows — discovery. Direct read of the
|
|
278
368
|
# registry catalog (name + description + the I/O schema contract). Not a
|
|
279
369
|
# Command; opt-in via the injected registry.
|
|
280
370
|
def handle_list_workflows
|
|
@@ -293,7 +383,8 @@ module Insika
|
|
|
293
383
|
body = parse_body(req)
|
|
294
384
|
payload = { workflow: name, agent: body[:agent],
|
|
295
385
|
input: body[:input], session_id: body[:session_id] }.compact
|
|
296
|
-
workflow_flow(payload, stream: req.GET["stream"] == "true"
|
|
386
|
+
workflow_flow(payload, stream: req.GET["stream"] == "true",
|
|
387
|
+
tenant: req_tenant(req))
|
|
297
388
|
end
|
|
298
389
|
|
|
299
390
|
# POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
|
|
@@ -305,14 +396,20 @@ module Insika
|
|
|
305
396
|
return gate if gate
|
|
306
397
|
|
|
307
398
|
parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
|
|
308
|
-
|
|
399
|
+
tenant = req_tenant(req)
|
|
400
|
+
ensure_session(parsed[:user], tenant: tenant)
|
|
309
401
|
payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
|
|
310
402
|
payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
|
|
311
|
-
|
|
403
|
+
payload[:customer] = parsed[:customer] if parsed[:customer] # WS8: the memory scope handle
|
|
404
|
+
payload[:parts] = parsed[:parts] if parsed[:parts] # WS9: multimodal content parts
|
|
405
|
+
payload[:source] = parsed[:source] if parsed[:source] # WS9: voice-marked text
|
|
406
|
+
payload[:channel] = parsed[:channel] if parsed[:channel] # WS9 (saída): the channel's output capabilities
|
|
407
|
+
message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
|
|
408
|
+
tenant: tenant)
|
|
312
409
|
end
|
|
313
410
|
|
|
314
411
|
# POST /v1/agents — provisions (upserts) an agent from a standardized
|
|
315
|
-
# PACK
|
|
412
|
+
# PACK. Same Bearer as /v1/responses (gateway_token,
|
|
316
413
|
# fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
|
|
317
414
|
# JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
|
|
318
415
|
# Raw body (string keys): the pack's file/skill names are data keys,
|
|
@@ -326,7 +423,7 @@ module Insika
|
|
|
326
423
|
end
|
|
327
424
|
|
|
328
425
|
# POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
|
|
329
|
-
#
|
|
426
|
+
# Same Bearer as provisioning (gateway_token, fail-
|
|
330
427
|
# closed): it's an authoring/provisioning surface and resolves the
|
|
331
428
|
# deployment's secrets. RAW body (string keys): the JSON Schema property names and
|
|
332
429
|
# the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
|
|
@@ -341,7 +438,7 @@ module Insika
|
|
|
341
438
|
json_response(200, dispatch_with_timeout(command))
|
|
342
439
|
end
|
|
343
440
|
|
|
344
|
-
# POST /v1/mcp/:name/import — LIVE MCP ingestion
|
|
441
|
+
# POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
|
|
345
442
|
# Bearer as provisioning (gateway_token, fail-closed): it's an authoring
|
|
346
443
|
# surface. Discovers the tools of the MCP instance `:name` (via a client injectable
|
|
347
444
|
# at the composition root) and ingests them as data-tools (reuses :import_tools:
|
|
@@ -367,7 +464,7 @@ module Insika
|
|
|
367
464
|
|
|
368
465
|
# GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
|
|
369
466
|
# (a client: it never reads a store) can tell "this case cannot run here" from
|
|
370
|
-
# "this case failed"
|
|
467
|
+
# "this case failed". Deliberately NOT the profile: the prompt,
|
|
371
468
|
# the model and the guardrail config are none of the caller's business. Just the
|
|
372
469
|
# two facts a case declares `requires` against.
|
|
373
470
|
#
|
|
@@ -400,26 +497,85 @@ module Insika
|
|
|
400
497
|
end
|
|
401
498
|
|
|
402
499
|
# Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
|
|
403
|
-
# ok (the handler proceeds).
|
|
500
|
+
# ok (the handler proceeds). In multi_tenant mode the resolved principal
|
|
501
|
+
# is stashed on the request env: `tenant_principal?`/`req_tenant` read it
|
|
502
|
+
# back for the surface gate and the command stamping.
|
|
404
503
|
def gateway_gate(req)
|
|
405
|
-
|
|
504
|
+
result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
|
|
505
|
+
req.get_header("HTTP_AUTHORIZATION"))
|
|
506
|
+
case result
|
|
406
507
|
when :disabled then auth_error(503, "gateway disabled")
|
|
407
508
|
when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
|
|
509
|
+
else
|
|
510
|
+
req.set_header("insika.principal", result)
|
|
511
|
+
nil
|
|
408
512
|
end
|
|
409
513
|
end
|
|
410
514
|
|
|
411
|
-
#
|
|
515
|
+
# -> bool: is the requester a TENANT principal (multi_tenant mode only)?
|
|
516
|
+
# The surface gate and the session/task read gates consume it; an operator
|
|
517
|
+
# principal is NOT a tenant (it has the run of the deployment, exactly as
|
|
518
|
+
# in single_tenant mode).
|
|
519
|
+
def tenant_principal?(req)
|
|
520
|
+
p = req.get_header("insika.principal")
|
|
521
|
+
p && p[:role] == "tenant"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The tenant the request operates AS: nil for an operator/classic request.
|
|
525
|
+
def req_tenant(req)
|
|
526
|
+
p = req.get_header("insika.principal")
|
|
527
|
+
p && p[:role] == "tenant" ? p[:tenant_id] : nil
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# A tenant may reach ONLY its own runtime surfaces. Everything else
|
|
531
|
+
# (commands, provisioning, authoring, config) is the operator's. An
|
|
532
|
+
# unknown route is NOT here -> a tenant is refused (the surface exists —
|
|
533
|
+
# just not for them), not told it is missing.
|
|
534
|
+
TENANT_SURFACES = [
|
|
535
|
+
["POST", ["v1", "sessions"]],
|
|
536
|
+
["POST", ["v1", "messages"]],
|
|
537
|
+
["POST", ["v1", "responses"]],
|
|
538
|
+
["POST", ["v1", "outcomes"]],
|
|
539
|
+
["GET", ["v1", "outcomes"]],
|
|
540
|
+
["POST", ["v1", "workflows", nil]],
|
|
541
|
+
["GET", ["v1", "workflows"]],
|
|
542
|
+
["GET", ["v1", "sessions", nil]],
|
|
543
|
+
["GET", ["v1", "tasks", nil]],
|
|
544
|
+
["GET", ["v1", "events"]]
|
|
545
|
+
].freeze
|
|
546
|
+
private_constant :TENANT_SURFACES
|
|
547
|
+
|
|
548
|
+
def tenant_surface?(method, segments)
|
|
549
|
+
TENANT_SURFACES.any? do |m, s|
|
|
550
|
+
m == method && s.zip(segments).all? { |pattern, got| pattern.nil? || pattern == got }
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Session id namespacing (WS1): a tenant's session lives under
|
|
555
|
+
# "<tenant>:<id>", so two tenants using the SAME chat id never share a
|
|
556
|
+
# session — the key itself is the isolation, not a convention. ":"
|
|
557
|
+
# (never "/") so the id stays one URL path segment; the same delimiter
|
|
558
|
+
# convention as the channels' "<channel>:<external_id>". Idempotent (a
|
|
559
|
+
# caller passing its own namespaced id back is not double-prefixed).
|
|
560
|
+
def scoped_session_id(tenant, id)
|
|
561
|
+
return id if tenant.nil? || id.nil? || id.to_s.empty?
|
|
562
|
+
return id if id.to_s.start_with?("#{tenant}:")
|
|
563
|
+
|
|
564
|
+
"#{tenant}:#{id}"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# POST /channels/:id/events — the Shape B inbound webhook.
|
|
412
568
|
# ACK FAST and never the reply: the platform (or the relay consumer) is holding
|
|
413
569
|
# a connection open with a retry timer on it, so this dispatches the turn and
|
|
414
570
|
# answers with its id. The answer itself leaves later, out of band, through the
|
|
415
|
-
# channel's own `deliver
|
|
571
|
+
# channel's own `deliver`.
|
|
416
572
|
#
|
|
417
573
|
# The channel does ALL the translating — auth, envelope, session correlation —
|
|
418
574
|
# and this handler stays what `server/` is allowed to be: a route that turns a
|
|
419
575
|
# request into a Command. Four answers, and each one is a different fact:
|
|
420
576
|
# 202 {task_id} a turn is running; its reply will be delivered
|
|
421
|
-
# 200 {task_id, duplicate} we already ran this event id
|
|
422
|
-
# 200 {task_id, merged} it joined a turn at the door
|
|
577
|
+
# 200 {task_id, duplicate} we already ran this event id
|
|
578
|
+
# 200 {task_id, merged} it joined a turn at the door
|
|
423
579
|
# 200 {task_id, steered} it was appended to a turn already running
|
|
424
580
|
# A consumer that treats the last three as 202 delivers the same answer twice.
|
|
425
581
|
def handle_channel_event(req, id)
|
|
@@ -435,11 +591,56 @@ module Insika
|
|
|
435
591
|
|
|
436
592
|
payload = { agent: parsed[:agent], session_id: session_id,
|
|
437
593
|
message: parsed[:message], event_id: parsed[:event_id] }.compact
|
|
438
|
-
channel_ack(payload, transport: :"channel:#{id}")
|
|
594
|
+
ack = channel_ack(payload, transport: :"channel:#{id}")
|
|
595
|
+
# in shadow the mirror's own reply may ride the SAME call
|
|
596
|
+
# (Shape 1) — recorded before we answer, through the one command.
|
|
597
|
+
if channel.respond_to?(:shadow?) && channel.shadow?
|
|
598
|
+
dispatch_shadow_reply(id, parsed) if parsed[:incumbent_reply]
|
|
599
|
+
return shadow_ack(ack)
|
|
600
|
+
end
|
|
601
|
+
ack
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# POST /channels/:id/shadow-reply — the mirror contract's fallback (Shape 2),
|
|
605
|
+
# for a consumer that mirrors the exchange BEFORE it answers the customer.
|
|
606
|
+
# Same channel_gate (auth is the channel's, not the route's); 404 unless the
|
|
607
|
+
# channel is in shadow — the same parity every other optional surface has.
|
|
608
|
+
def handle_channel_shadow_reply(req, id)
|
|
609
|
+
channel = @channels.find(id)
|
|
610
|
+
return not_found if channel.nil? || !(channel.respond_to?(:shadow?) && channel.shadow?)
|
|
611
|
+
|
|
612
|
+
gate = channel_gate(channel, req)
|
|
613
|
+
return gate if gate
|
|
614
|
+
|
|
615
|
+
parsed = channel.parse_shadow_reply(req, body: parse_raw_body(req)) # ValidationError -> 422
|
|
616
|
+
result = dispatch_shadow_reply(id, parsed)
|
|
617
|
+
json_response(202, { pair_id: result[:pair_id], status: result[:status] })
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# The incumbent half rides ONE command whatever shape it arrived in, so
|
|
621
|
+
# server/ never writes to a store and both doors share one behaviour.
|
|
622
|
+
def dispatch_shadow_reply(id, parsed)
|
|
623
|
+
@command_bus.dispatch(
|
|
624
|
+
Insika::Command.build(:record_shadow_reply,
|
|
625
|
+
{ channel: id.to_s, external_id: parsed[:external_id],
|
|
626
|
+
event_id: parsed[:event_id], reply: parsed[:incumbent_reply] || parsed[:reply],
|
|
627
|
+
at: parsed[:at] }.compact,
|
|
628
|
+
transport: :"channel:#{id}")
|
|
629
|
+
)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# 202 -> 200 {task_id, shadow: true}: a consumer wired to "202 means a
|
|
633
|
+
# reply is coming" must not be silently misled. The duplicate/merged/
|
|
634
|
+
# steered verdicts pass through untouched (they already say 200).
|
|
635
|
+
def shadow_ack(ack)
|
|
636
|
+
status, _headers, body = ack
|
|
637
|
+
return ack unless status == 202
|
|
638
|
+
|
|
639
|
+
json_response(200, JSON.parse(body.join).merge("shadow" => true))
|
|
439
640
|
end
|
|
440
641
|
|
|
441
642
|
# POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
|
|
442
|
-
# channel
|
|
643
|
+
# channel. The engine issues the id and the client never
|
|
443
644
|
# proposes one: an endpoint that created a session from a caller-supplied id
|
|
444
645
|
# would let anyone read someone else's conversation by guessing.
|
|
445
646
|
#
|
|
@@ -523,7 +724,7 @@ module Insika
|
|
|
523
724
|
end
|
|
524
725
|
|
|
525
726
|
# Does this session exist AND belong to this channel? `vars["channel"]` is
|
|
526
|
-
# written when the session is minted
|
|
727
|
+
# written when the session is minted.
|
|
527
728
|
def channel_session?(id, session_id)
|
|
528
729
|
session = session_id && @session_store.find(session_id)
|
|
529
730
|
return false if session.nil?
|
|
@@ -569,7 +770,7 @@ module Insika
|
|
|
569
770
|
json_response(202, { task_id: result[:task_id] })
|
|
570
771
|
end
|
|
571
772
|
|
|
572
|
-
#
|
|
773
|
+
# `channel` + `external_id` on the session are how a later turn
|
|
573
774
|
# (and the outbox) know where a reply goes. The consumer's own `vars` ride along
|
|
574
775
|
# on first contact, but never over those two — a caller must not be able to
|
|
575
776
|
# rewrite its own conversation's address.
|
|
@@ -582,19 +783,29 @@ module Insika
|
|
|
582
783
|
# namespaced `<channel>:<external_id>` for a channel): creates if new, continues
|
|
583
784
|
# if it exists (multi-turn). Via Command (server/ does not write to a store).
|
|
584
785
|
# Benign race (two near-simultaneous turns creating) -> ArgumentError from the
|
|
585
|
-
# store, treated as "already exists".
|
|
586
|
-
|
|
786
|
+
# store, treated as "already exists". A TENANT's id is namespaced (WS1), so
|
|
787
|
+
# two tenants with the same chat id get two isolated sessions.
|
|
788
|
+
def ensure_session(id, vars: { channel: "responses" }, tenant: nil)
|
|
789
|
+
id = scoped_session_id(tenant, id)
|
|
587
790
|
return if @session_store.find(id)
|
|
588
791
|
|
|
589
792
|
@command_bus.dispatch(
|
|
590
|
-
Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http
|
|
793
|
+
Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
|
|
794
|
+
tenant: tenant)
|
|
591
795
|
)
|
|
592
796
|
rescue ArgumentError
|
|
593
797
|
nil
|
|
594
798
|
end
|
|
595
799
|
|
|
596
|
-
# GET /v1/sessions/:id — direct read (not a Command).
|
|
597
|
-
|
|
800
|
+
# GET /v1/sessions/:id — direct read (not a Command). A tenant may only
|
|
801
|
+
# read its OWN sessions: the ownership is the id namespace itself (its
|
|
802
|
+
# sessions live under "<tenant>:…"), anything else reads as a 404.
|
|
803
|
+
def handle_read_session(req, id)
|
|
804
|
+
tenant = req_tenant(req)
|
|
805
|
+
if tenant && !id.to_s.start_with?("#{tenant}:")
|
|
806
|
+
raise Insika::NotFoundError, "session not found: #{id}"
|
|
807
|
+
end
|
|
808
|
+
|
|
598
809
|
session = @session_store.find(id)
|
|
599
810
|
raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
|
|
600
811
|
|
|
@@ -603,11 +814,18 @@ module Insika
|
|
|
603
814
|
|
|
604
815
|
# GET /v1/tasks/:id — direct read. This is where the consumer observes
|
|
605
816
|
# PolicyDenied/post-202 failures: the terminal state lives in the Task
|
|
606
|
-
# Store; nothing is lost if the client disconnected.
|
|
607
|
-
|
|
817
|
+
# Store; nothing is lost if the client disconnected. A tenant may only
|
|
818
|
+
# read a task its own command stamped (the task record carries the
|
|
819
|
+
# command with its meta.tenant) — someone else's reads as a 404.
|
|
820
|
+
def handle_read_task(req, id)
|
|
608
821
|
task = @task_store.find(id)
|
|
609
822
|
raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
|
|
610
823
|
|
|
824
|
+
tenant = req_tenant(req)
|
|
825
|
+
if tenant && task_tenant(task) != tenant
|
|
826
|
+
raise Insika::NotFoundError, "task not found: #{id}"
|
|
827
|
+
end
|
|
828
|
+
|
|
611
829
|
body = { task: task_to_h(task) }
|
|
612
830
|
# pending approvals: this is where the consumer/operator sees
|
|
613
831
|
# what needs approval after an :approval_requested.
|
|
@@ -617,12 +835,24 @@ module Insika
|
|
|
617
835
|
json_response(200, body)
|
|
618
836
|
end
|
|
619
837
|
|
|
838
|
+
# The tenant stamped on the task's persisted command (WS1): string or
|
|
839
|
+
# symbol keys, whichever the store round-trip produced.
|
|
840
|
+
def task_tenant(task)
|
|
841
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
842
|
+
return nil unless command.is_a?(Hash)
|
|
843
|
+
|
|
844
|
+
meta = command["meta"] || command[:meta] || {}
|
|
845
|
+
meta["tenant"] || meta[:tenant]
|
|
846
|
+
end
|
|
847
|
+
|
|
620
848
|
# GET /v1/events?task_id=&session_id= — here the filters ARE known.
|
|
621
849
|
# CONTINUOUS stream (post-crash reconnection route): does not close on a
|
|
622
|
-
# terminal event — ends on client disconnect or cap.
|
|
850
|
+
# terminal event — ends on client disconnect or cap. A tenant's stream is
|
|
851
|
+
# scoped to its own events (fail-closed on the event's meta.tenant).
|
|
623
852
|
def handle_events(req)
|
|
624
853
|
subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
|
|
625
|
-
session_id: req.GET["session_id"]
|
|
854
|
+
session_id: req.GET["session_id"],
|
|
855
|
+
tenant: req_tenant(req))
|
|
626
856
|
sse_response(subscription)
|
|
627
857
|
end
|
|
628
858
|
|
|
@@ -650,8 +880,45 @@ module Insika
|
|
|
650
880
|
# transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
|
|
651
881
|
# happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
|
|
652
882
|
# #call rescue (becomes an HTTP status).
|
|
653
|
-
def message_flow(payload, stream:, serialize: nil, transport: :http)
|
|
654
|
-
|
|
883
|
+
def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
|
|
884
|
+
# WS9: content parts are CONTRACT at the edge — the closed shape is
|
|
885
|
+
# validated here (422) before the command is built; the engine stays
|
|
886
|
+
# lenient for transports that bypass this surface.
|
|
887
|
+
parts = payload[:parts] || payload["parts"]
|
|
888
|
+
if parts && !Insika::Media.well_formed?(parts)
|
|
889
|
+
raise Insika::ValidationError,
|
|
890
|
+
"malformed content part — each part must be {type: text|image|audio} with text/url"
|
|
891
|
+
end
|
|
892
|
+
# WS9: the only declared message source is "voice" (pre-transcribed
|
|
893
|
+
# audio) — a consumer that writes prose here is told so, not silently.
|
|
894
|
+
source = payload[:source] || payload["source"]
|
|
895
|
+
if source && source.to_s != "voice"
|
|
896
|
+
raise Insika::ValidationError, 'source must be "voice"'
|
|
897
|
+
end
|
|
898
|
+
# WS9 (saída): the channel's declared OUTPUT media capabilities. The
|
|
899
|
+
# closed list IS the "abstraction admits only what leaks" rule: an
|
|
900
|
+
# unknown value is refused here (422), never silently ignored; a
|
|
901
|
+
# declared value is what the executor may wire (generate_image/tts).
|
|
902
|
+
channel = payload[:channel] || payload["channel"]
|
|
903
|
+
if channel.is_a?(Hash)
|
|
904
|
+
caps = Insika::Media.channel_capabilities(channel)
|
|
905
|
+
unknown = caps - Insika::Media::OUTPUT_CAPABILITIES
|
|
906
|
+
unless unknown.empty?
|
|
907
|
+
raise Insika::ValidationError,
|
|
908
|
+
"unknown channel capability: #{unknown.join(', ')}"
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
payload = payload.dup
|
|
912
|
+
payload[:channel] = { capabilities: caps }
|
|
913
|
+
end
|
|
914
|
+
# WS1: a tenant's session_id is NAMESPACED before the command is built,
|
|
915
|
+
# so the turn lands on the tenant's OWN session even when another tenant
|
|
916
|
+
# uses the same chat id. The payload the caller sent is untouched.
|
|
917
|
+
if tenant
|
|
918
|
+
payload = payload.dup
|
|
919
|
+
payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
|
|
920
|
+
end
|
|
921
|
+
command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
|
|
655
922
|
subscription = @event_stream.subscribe
|
|
656
923
|
result =
|
|
657
924
|
begin
|
|
@@ -661,7 +928,7 @@ module Insika
|
|
|
661
928
|
raise
|
|
662
929
|
end
|
|
663
930
|
|
|
664
|
-
#
|
|
931
|
+
# the message joined another turn — one still waiting at the
|
|
665
932
|
# door (`merged`) or one already running (`steered`). Either way this call
|
|
666
933
|
# owns no reply; the one holding `task_id` does. Say exactly that and open no
|
|
667
934
|
# stream: a caller that delivered this response's (empty) output would
|
|
@@ -682,14 +949,20 @@ module Insika
|
|
|
682
949
|
stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
|
|
683
950
|
end
|
|
684
951
|
|
|
685
|
-
# Workflow trigger flow
|
|
952
|
+
# Workflow trigger flow. Async by default (the honest workflow
|
|
686
953
|
# contract: fire the run, return the runId); ?stream=true streams the run's
|
|
687
954
|
# events like a turn. Same subscribe-before-dispatch discipline as
|
|
688
955
|
# message_flow so no eager event is lost when streaming. A synchronous handler
|
|
689
956
|
# error (bad input / unknown workflow) closes the subscription and propagates
|
|
690
957
|
# to #call (HTTP status).
|
|
691
|
-
def workflow_flow(payload, stream:)
|
|
692
|
-
|
|
958
|
+
def workflow_flow(payload, stream:, tenant: nil)
|
|
959
|
+
# WS1: same session-id namespacing as message_flow — the workflow's run
|
|
960
|
+
# session is the tenant's own.
|
|
961
|
+
if tenant
|
|
962
|
+
payload = payload.dup
|
|
963
|
+
payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
|
|
964
|
+
end
|
|
965
|
+
command = Insika::Command.build(:trigger_workflow, payload, transport: :http, tenant: tenant)
|
|
693
966
|
|
|
694
967
|
unless stream
|
|
695
968
|
result = dispatch_with_timeout(command)
|
|
@@ -724,7 +997,11 @@ module Insika
|
|
|
724
997
|
events << event.to_h
|
|
725
998
|
case event.type
|
|
726
999
|
when :content then content << event.data[:delta].to_s
|
|
727
|
-
when :task_failed
|
|
1000
|
+
when :task_failed
|
|
1001
|
+
error = { class: event.data[:error], message: event.data[:message] }
|
|
1002
|
+
# A8: the classification rides through when the executor wrapped
|
|
1003
|
+
# the failure (ProviderError) — additive for every other terminal.
|
|
1004
|
+
error = error.merge(event.data.slice(:kind, :retryable, :retry_after))
|
|
728
1005
|
when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
|
|
729
1006
|
when :error then error ||= { class: nil, message: event.data[:message] }
|
|
730
1007
|
end
|
|
@@ -812,15 +1089,50 @@ module Insika
|
|
|
812
1089
|
json_response(status, { error: { class: error.class.name, message: error.message } })
|
|
813
1090
|
end
|
|
814
1091
|
|
|
1092
|
+
# the 500 is the ONE status whose body carries the retry envelope —
|
|
1093
|
+
# retryable/retry_after tell the client what to do, error_ref is what it
|
|
1094
|
+
# quotes when the retry keeps failing. The SAME ref is logged here, or the
|
|
1095
|
+
# field is decoration. 4xx stay bare: a client error is fixed by editing
|
|
1096
|
+
# the request, not by waiting.
|
|
1097
|
+
def internal_error_response(error)
|
|
1098
|
+
ref = "err_#{SecureRandom.hex(8)}"
|
|
1099
|
+
# Observability only (shutdown.rb's rule): a logger failure must never
|
|
1100
|
+
# mask the 500 the client is owed.
|
|
1101
|
+
begin
|
|
1102
|
+
@logger&.puts("[server] #{ref} #{error.class}: #{error.message}\n" \
|
|
1103
|
+
"#{Array(error.backtrace).first(5).join("\n")}")
|
|
1104
|
+
rescue StandardError
|
|
1105
|
+
nil
|
|
1106
|
+
end
|
|
1107
|
+
# B9/A8: a classified ProviderError quotes its own retry guidance;
|
|
1108
|
+
# everything else keeps the blanket retry (the caller may have died mid
|
|
1109
|
+
# request — a bounded wait is the safe default).
|
|
1110
|
+
retryable = error.respond_to?(:retryable) && !error.retryable.nil? ? error.retryable : true
|
|
1111
|
+
retry_after = error.respond_to?(:retry_after) && error.retry_after ? error.retry_after : RETRY_AFTER_SECONDS
|
|
1112
|
+
body = { error: { class: error.class.name, message: error.message,
|
|
1113
|
+
retryable: retryable, retry_after: retry_after, error_ref: ref } }
|
|
1114
|
+
body[:error][:kind] = error.kind if error.respond_to?(:kind) && error.kind
|
|
1115
|
+
json_response(500, body)
|
|
1116
|
+
end
|
|
1117
|
+
|
|
815
1118
|
def not_found
|
|
816
1119
|
[404, { "content-type" => "text/plain" }, ["not found"]]
|
|
817
1120
|
end
|
|
818
1121
|
|
|
819
1122
|
# Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
|
|
820
|
-
# has already run (Boot only returns the app afterward
|
|
1123
|
+
# has already run (Boot only returns the app afterward), it's ready. Does NOT
|
|
821
1124
|
# touch a store (health cannot fail on IO nor require auth).
|
|
822
1125
|
def health = json_response(200, { status: "ok" })
|
|
823
1126
|
|
|
1127
|
+
# process vitals. Operator-only by construction: not in
|
|
1128
|
+
# PUBLIC_ROUTES (no bearer -> unauthorized) and not in TENANT_SURFACES
|
|
1129
|
+
# (a tenant principal hits the operator-surface gate), so only an
|
|
1130
|
+
# operator reads process internals. Reads no store — pure OS/VM
|
|
1131
|
+
# readings, safe at any rate.
|
|
1132
|
+
def handle_vitals
|
|
1133
|
+
json_response(200, Insika::Vitals.snapshot(executor: @executor, db_path: @db_path))
|
|
1134
|
+
end
|
|
1135
|
+
|
|
824
1136
|
# Thin Subscription decorator: discards
|
|
825
1137
|
# events from OTHER tasks and CLOSES the subscription after forwarding the task's
|
|
826
1138
|
# terminal event. Solves the subscribe-before-task_id gap without touching
|