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,1022 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack"
|
|
5
|
+
require "async"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "sse_body"
|
|
8
|
+
require_relative "tenant_auth" # WS1: Bearer -> { role:, tenant_id: } (single/multi-tenant)
|
|
9
|
+
require_relative "a2a/app" # A2A edge adapter (pulls protocol/errors/message/projection/card)
|
|
10
|
+
require_relative "responses" # OpenAI Responses adapter (/v1/responses) — drop-in for the OpenClaw gateway
|
|
11
|
+
|
|
12
|
+
module Insika
|
|
13
|
+
module Server
|
|
14
|
+
# Rack app. Transports ONLY
|
|
15
|
+
# translate requests into Commands — the server holds no business
|
|
16
|
+
# logic. It parses JSON, builds `Command.build(...)`, dispatches on the
|
|
17
|
+
# CommandBus and projects the Event Stream to SSE. Reads are NOT Commands: they are
|
|
18
|
+
# direct reads from the stores.
|
|
19
|
+
#
|
|
20
|
+
# AUDITABLE constitutional rule: `server/` does not import the Executor,
|
|
21
|
+
# store WRITE methods, or RubyLLM. Requires: json, rack, async and the
|
|
22
|
+
# core types (Command/Event/errors) already loaded by the composition root.
|
|
23
|
+
class App
|
|
24
|
+
SSE_HEADERS = {
|
|
25
|
+
"content-type" => "text/event-stream",
|
|
26
|
+
"cache-control" => "no-cache",
|
|
27
|
+
"connection" => "keep-alive"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Terminal events of a turn (close the task subscription in the
|
|
31
|
+
# transport). The overflow :error self-closes its own subscription at the
|
|
32
|
+
# EventStream (enqueues CLOSED), so it needs no entry here to end `each`.
|
|
33
|
+
TERMINAL_EVENTS = %i[task_completed task_failed task_cancelled].freeze
|
|
34
|
+
private_constant :TERMINAL_EVENTS
|
|
35
|
+
|
|
36
|
+
# the `/v1` contract, versioned by date. A caller PINS behaviour
|
|
37
|
+
# with `Insika-Version: YYYY-MM-DD` so a future breaking change does not move
|
|
38
|
+
# silently underneath it; absent header = today's (only) version. Only one
|
|
39
|
+
# entry exists so far — the day a second one is added, the routes that
|
|
40
|
+
# changed branch on this value instead of being served whichever behaviour
|
|
41
|
+
# happened to be current.
|
|
42
|
+
KNOWN_VERSIONS = ["2026-08-08"].freeze
|
|
43
|
+
private_constant :KNOWN_VERSIONS
|
|
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
|
|
53
|
+
# pure transport surface (/v1, /a2a). The constitutional rule holds: server/
|
|
54
|
+
# only READS stores and never imports the Executor, store writes, or RubyLLM.
|
|
55
|
+
def initialize(command_bus:, event_stream:, session_store:, task_store:,
|
|
56
|
+
config:, pending_action_store: nil, a2a: nil, provisioner: nil,
|
|
57
|
+
workflow_registry: nil, onboarding: nil, profiles: nil,
|
|
58
|
+
channels: nil, logger: nil, token_store: nil)
|
|
59
|
+
@command_bus = command_bus
|
|
60
|
+
@event_stream = event_stream
|
|
61
|
+
@session_store = session_store
|
|
62
|
+
@task_store = task_store
|
|
63
|
+
@config = config
|
|
64
|
+
@pending_action_store = pending_action_store # read for GET /v1/tasks/:id
|
|
65
|
+
@a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
|
|
66
|
+
@provisioner = provisioner # PackImporter. nil = provisioning not exposed.
|
|
67
|
+
# WS1 multi-tenant credentials. nil = single_tenant mode (the classic
|
|
68
|
+
# single operator credential, gateway_token). Present = tokens resolve
|
|
69
|
+
# from the store (per-tenant + operator), gateway_token still resolves
|
|
70
|
+
# as operator (an existing deployment switching modes keeps its token).
|
|
71
|
+
@token_store = token_store
|
|
72
|
+
# "single_tenant" (default, parity) | "multi_tenant".
|
|
73
|
+
@tenancy = config.fetch(:tenancy, "single_tenant")
|
|
74
|
+
# READ-ONLY registry, injected only where workflows are
|
|
75
|
+
# exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
|
|
76
|
+
# deployment does not expose workflows). Reading a catalog is a READ, like a
|
|
77
|
+
# store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
|
|
78
|
+
@workflow_registry = workflow_registry
|
|
79
|
+
# LLM-first onboarding surface (start.md + models.json +
|
|
80
|
+
# docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
|
|
81
|
+
# is that the developer's coding agent can fetch it), and READ-ONLY, so the
|
|
82
|
+
# constitutional rule holds. nil = routes not exposed (parity — the production
|
|
83
|
+
# deployment opts in). Reading files/masked stores is a READ, like a store read.
|
|
84
|
+
@onboarding = onboarding
|
|
85
|
+
# READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
|
|
86
|
+
# what an agent has — the eval is a client and cannot read a store. Same
|
|
87
|
+
# constitutional footing as the workflow registry: reading a catalog is a
|
|
88
|
+
# READ. nil = the route 404s (parity).
|
|
89
|
+
@profiles = profiles
|
|
90
|
+
# ONE generic route family for every channel, opt-in by
|
|
91
|
+
# injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
|
|
92
|
+
# The channel does the translating; this class keeps doing only transport.
|
|
93
|
+
@channels = channels
|
|
94
|
+
# where a 500's error_ref goes to be FOUND. nil = silent (parity for
|
|
95
|
+
# embedders); the serving wirings pass $stdout. Class+message+backtrace
|
|
96
|
+
# only — the ref never travels with request payloads (secrets stay out).
|
|
97
|
+
@logger = logger
|
|
98
|
+
@heartbeat = config.fetch(:heartbeat, 15)
|
|
99
|
+
@sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Explicit routing, NO framework: ~10 routes in a `case`. A single
|
|
103
|
+
# `rescue` centralizes the error->status mapping. Only SYNCHRONOUS
|
|
104
|
+
# errors (before the fiber) become HTTP status; a task failure travels as
|
|
105
|
+
# an event on the stream and lands in GET /v1/tasks/:id.
|
|
106
|
+
def call(env)
|
|
107
|
+
req = Rack::Request.new(env)
|
|
108
|
+
route(req)
|
|
109
|
+
rescue JSON::ParserError => e
|
|
110
|
+
error_response(400, e) # malformed JSON, before any dispatch
|
|
111
|
+
rescue Insika::ValidationError => e
|
|
112
|
+
error_response(422, e)
|
|
113
|
+
rescue Insika::NotFoundError => e
|
|
114
|
+
error_response(404, e)
|
|
115
|
+
rescue Async::TimeoutError => e
|
|
116
|
+
error_response(504, e) # synchronous control request exceeded the ceiling
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
internal_error_response(e)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def route(req)
|
|
124
|
+
# UTF-8, not the ASCII-8BIT Rack hands us. A path segment becomes a STORE KEY
|
|
125
|
+
# (`/v1/agents/:id`, `/v1/sessions/:id`), and the sqlite3 driver binds a
|
|
126
|
+
# BINARY string as a BLOB — which never matches a TEXT column. So every such
|
|
127
|
+
# read answered 404 on a durable deployment while passing every spec, because
|
|
128
|
+
# the in-memory store is a Ruby Hash and a binary string is `eql?` to its
|
|
129
|
+
# UTF-8 twin. Found by calling `GET /v1/agents/:id` against a real database.
|
|
130
|
+
segments = req.path_info.split("/").reject(&:empty?).map { |s| Coercion.utf8(s) }
|
|
131
|
+
if segments.first == "v1"
|
|
132
|
+
version_error = version_gate(req)
|
|
133
|
+
return version_error if version_error
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
|
|
137
|
+
return gate if gate
|
|
138
|
+
# WS1: a TENANT principal is confined to its own runtime surfaces (chat
|
|
139
|
+
# + its own reads). Every authoring/provisioning surface stays
|
|
140
|
+
# operator-only — a tenant can never mint tokens, author tools or
|
|
141
|
+
# change platform config. single_tenant (no principal) is untouched.
|
|
142
|
+
if tenant_principal?(req) && !tenant_surface?(req.request_method, segments)
|
|
143
|
+
return auth_error(403, "operator surface")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
case [req.request_method, segments]
|
|
147
|
+
in ["GET", ["up"]]
|
|
148
|
+
health # readiness/liveness (Railway/k8s) — no auth, no store access
|
|
149
|
+
in ["GET", ["start.md"]] if @onboarding
|
|
150
|
+
markdown_response(200, @onboarding.start_md(base_url: public_base(req)))
|
|
151
|
+
in ["GET", ["models.json"]] if @onboarding
|
|
152
|
+
json_response(200, @onboarding.models_json(base_url: public_base(req)))
|
|
153
|
+
in ["GET", ["docs"]] if @onboarding
|
|
154
|
+
json_response(200, { docs: @onboarding.docs_index(base_url: public_base(req)) })
|
|
155
|
+
in ["GET", ["docs", file]] if @onboarding && file.end_with?(".md")
|
|
156
|
+
handle_doc(file)
|
|
157
|
+
in ["POST", ["v1", "commands", type]]
|
|
158
|
+
handle_command(req, type)
|
|
159
|
+
in ["POST", ["v1", "sessions"]]
|
|
160
|
+
handle_create_session(req)
|
|
161
|
+
in ["POST", ["v1", "messages"]]
|
|
162
|
+
handle_send_message(req)
|
|
163
|
+
in ["GET", ["v1", "workflows"]] if @workflow_registry
|
|
164
|
+
handle_list_workflows
|
|
165
|
+
in ["POST", ["v1", "workflows", name]] if @workflow_registry
|
|
166
|
+
handle_trigger_workflow(req, name)
|
|
167
|
+
in ["POST", ["v1", "responses"]]
|
|
168
|
+
handle_responses(req)
|
|
169
|
+
in ["POST", ["v1", "tools", "manifest"]]
|
|
170
|
+
handle_import_tools(req)
|
|
171
|
+
in ["POST", ["v1", "mcp", name, "import"]]
|
|
172
|
+
handle_import_mcp_tools(req, name)
|
|
173
|
+
in ["POST", ["v1", "agents"]] if @provisioner
|
|
174
|
+
handle_provision(req)
|
|
175
|
+
in ["DELETE", ["v1", "agents", id]] if @provisioner
|
|
176
|
+
handle_deprovision(req, id)
|
|
177
|
+
in ["GET", ["v1", "agents", id]] if @profiles
|
|
178
|
+
handle_read_agent(id)
|
|
179
|
+
in ["GET", ["v1", "sessions", id]]
|
|
180
|
+
handle_read_session(req, id)
|
|
181
|
+
in ["GET", ["v1", "tasks", id]]
|
|
182
|
+
handle_read_task(req, id)
|
|
183
|
+
in ["GET", ["v1", "events"]]
|
|
184
|
+
handle_events(req)
|
|
185
|
+
in ["POST", ["channels", id, "events"]] if @channels
|
|
186
|
+
handle_channel_event(req, id)
|
|
187
|
+
in ["POST", ["channels", id, "sessions"]] if @channels
|
|
188
|
+
handle_channel_session(req, id)
|
|
189
|
+
in ["POST", ["channels", id, "messages"]] if @channels
|
|
190
|
+
handle_channel_message(req, id)
|
|
191
|
+
in ["GET", ["channels", id, "asset", file]] if @channels
|
|
192
|
+
handle_channel_asset(req, id, file)
|
|
193
|
+
in ["OPTIONS", ["channels", id, *]] if @channels
|
|
194
|
+
handle_channel_preflight(req, id)
|
|
195
|
+
in ["POST", ["a2a"]] if @a2a
|
|
196
|
+
handle_a2a(req)
|
|
197
|
+
in ["GET", [".well-known", "agent-card.json"]] if @a2a
|
|
198
|
+
json_response(200, @a2a.agent_card)
|
|
199
|
+
else
|
|
200
|
+
not_found # wrong method/route (or A2A not exposed -> @a2a nil)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The ONLY routes that answer without the gateway Bearer. Everything else is gated
|
|
205
|
+
# in `route`, before the dispatch — an ALLOWLIST, because the previous shape (each
|
|
206
|
+
# handler calling `gateway_gate` itself) is a rule you have to remember: the generic
|
|
207
|
+
# `POST /v1/commands/:type` never called it, so every authoring Command
|
|
208
|
+
# (`write_agent_file`, `upsert_llm_provider`, `delete_agent`…) was reachable by
|
|
209
|
+
# anyone who knew the URL, as were the session/task/event reads. A route added
|
|
210
|
+
# tomorrow is closed by default; making it public is now a deliberate edit here.
|
|
211
|
+
#
|
|
212
|
+
# `/up` is the health probe (no store access). The onboarding surface is opt-in
|
|
213
|
+
# (INSIKA_ONBOARDING) and exists to be read by a coding agent before it has any
|
|
214
|
+
# credential — turning it on is the operator choosing to publish it.
|
|
215
|
+
PUBLIC_ROUTES = [
|
|
216
|
+
["GET", ["up"]],
|
|
217
|
+
["GET", ["start.md"]],
|
|
218
|
+
["GET", ["models.json"]],
|
|
219
|
+
["GET", ["docs"]],
|
|
220
|
+
["GET", [".well-known", "agent-card.json"]] # A2A discovery: the card is the ad
|
|
221
|
+
].freeze
|
|
222
|
+
|
|
223
|
+
def public_route?(method, segments)
|
|
224
|
+
return true if PUBLIC_ROUTES.include?([method, segments])
|
|
225
|
+
return true if channel_route?(method, segments)
|
|
226
|
+
|
|
227
|
+
method == "GET" && segments.length == 2 && segments.first == "docs"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A channel route skips the GATEWAY bearer because the channel authenticates
|
|
231
|
+
# it ITSELF — with the platform's own scheme (a relay's shared secret, a Slack
|
|
232
|
+
# HMAC signature, the widget's origin allowlist plus its mandatory rate limit),
|
|
233
|
+
# which is the only credential the caller has. Requiring the gateway token here
|
|
234
|
+
# instead would mean handing every platform — and every anonymous browser — a
|
|
235
|
+
# second secret it has no way to send.
|
|
236
|
+
#
|
|
237
|
+
# This is NOT an ungated route family: every handler below calls `channel_gate`
|
|
238
|
+
# before parsing anything, and a channel that implements no `authenticate`, or
|
|
239
|
+
# whose credential is unconfigured, answers `:disabled` rather than open.
|
|
240
|
+
# ENUMERATED rather than prefix-matched, so a route added to this family
|
|
241
|
+
# tomorrow is gated by default and publishing it is a deliberate edit here.
|
|
242
|
+
def channel_route?(method, segments)
|
|
243
|
+
case [method, segments]
|
|
244
|
+
in ["POST", ["channels", _, "events" | "sessions" | "messages"]] then true
|
|
245
|
+
in ["GET", ["channels", _, "asset", _]] then true
|
|
246
|
+
in ["OPTIONS", ["channels", _, *]] then true # CORS preflight carries no credential, by spec
|
|
247
|
+
else false
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Bearer-gate error (503 disabled / 401 unauthorized), shared by the
|
|
252
|
+
# gateway surfaces. JSON body, fail-closed.
|
|
253
|
+
def auth_error(status, message, extra_headers = {})
|
|
254
|
+
[status,
|
|
255
|
+
{ "content-type" => "application/json" }.merge(extra_headers),
|
|
256
|
+
[JSON.generate(error: { class: "Insika::Error", message: message })]]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# POST /v1/commands/:type — generic: every new Command is born with a
|
|
260
|
+
# transport. The control vs turn distinction is BY THE SHAPE of the result (the
|
|
261
|
+
# transport knows no semantics).
|
|
262
|
+
def handle_command(req, type)
|
|
263
|
+
command = Insika::Command.build(type.to_sym, parse_body(req), transport: :http)
|
|
264
|
+
command_response(dispatch_with_timeout(command))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# POST /v1/sessions — sugar for create_session; 201 {session}.
|
|
268
|
+
def handle_create_session(req)
|
|
269
|
+
body = parse_body(req)
|
|
270
|
+
tenant = req_tenant(req)
|
|
271
|
+
# WS1: a tenant's session must be born under its OWN "<tenant>:" namespace
|
|
272
|
+
# — the read path (GET /v1/sessions/:id) refuses anything else. Scope the
|
|
273
|
+
# caller's id the same way message_flow scopes a session_id; a tenant that
|
|
274
|
+
# passed none gets a namespaced uuid instead of an unprefixed one it could
|
|
275
|
+
# never read back.
|
|
276
|
+
if tenant
|
|
277
|
+
id = body[:id] || body["id"]
|
|
278
|
+
id = Insika::Coercion.blank?(id) ? scoped_session_id(tenant, SecureRandom.uuid)
|
|
279
|
+
: scoped_session_id(tenant, id)
|
|
280
|
+
body = body.merge(id: id)
|
|
281
|
+
end
|
|
282
|
+
command = Insika::Command.build(:create_session,
|
|
283
|
+
{ vars: body[:vars] || {}, id: body[:id] }.compact,
|
|
284
|
+
transport: :http, tenant: tenant)
|
|
285
|
+
session = dispatch_with_timeout(command)
|
|
286
|
+
json_response(201, { session: session.to_h })
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# POST /v1/messages — sugar for send_message; ?stream missing/"true" -> SSE,
|
|
290
|
+
# "false" -> 200 JSON aggregated at the terminal event.
|
|
291
|
+
def handle_send_message(req)
|
|
292
|
+
stream = req.GET["stream"] != "false"
|
|
293
|
+
# only the aggregated-JSON form has room for the `merged`/`steered`
|
|
294
|
+
# verdict, so only it may join a message to another turn. Once the stream is open
|
|
295
|
+
# there is no way to tell the caller it does not own the reply.
|
|
296
|
+
message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
|
|
297
|
+
tenant: req_tenant(req))
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# GET /docs/:name.md — one public doc as raw markdown. The
|
|
301
|
+
# slug is a KEY of the onboarding allowlist, so no filesystem traversal is
|
|
302
|
+
# possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
|
|
303
|
+
def handle_doc(file)
|
|
304
|
+
markdown = @onboarding.doc(file.sub(/\.md\z/, ""))
|
|
305
|
+
return not_found if markdown.nil?
|
|
306
|
+
|
|
307
|
+
markdown_response(200, markdown)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Public base url for the interpolated onboarding links. Prefers an explicit
|
|
311
|
+
# config[:public_url] (behind a proxy/TLS terminator the request scheme is the
|
|
312
|
+
# internal http), else the request's own base_url.
|
|
313
|
+
def public_base(req)
|
|
314
|
+
Insika::Coercion.presence(@config[:public_url]) || req.base_url
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# GET /v1/workflows — discovery. Direct read of the
|
|
318
|
+
# registry catalog (name + description + the I/O schema contract). Not a
|
|
319
|
+
# Command; opt-in via the injected registry.
|
|
320
|
+
def handle_list_workflows
|
|
321
|
+
json_response(200, { workflows: @workflow_registry.catalog })
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# POST /v1/workflows/:name — triggers a workflow RUN. The name comes from the
|
|
325
|
+
# ROUTE; agent/input/session_id from the body. Two shapes:
|
|
326
|
+
# · default -> 202 { run_id, task_id } immediately (async at-most-once
|
|
327
|
+
# run; observe via GET /v1/tasks/:run_id or GET /v1/events?task_id=:run_id).
|
|
328
|
+
# · ?stream=true -> SSE of the run's events (incl. :workflow_started /
|
|
329
|
+
# :workflow_completed), closing at the terminal event.
|
|
330
|
+
# A bad input (input_schema) is a synchronous 422 with no run (WorkflowSchemaError
|
|
331
|
+
# -> ValidationError in #call); an unknown workflow/agent -> 404/422.
|
|
332
|
+
def handle_trigger_workflow(req, name)
|
|
333
|
+
body = parse_body(req)
|
|
334
|
+
payload = { workflow: name, agent: body[:agent],
|
|
335
|
+
input: body[:input], session_id: body[:session_id] }.compact
|
|
336
|
+
workflow_flow(payload, stream: req.GET["stream"] == "true",
|
|
337
|
+
tenant: req_tenant(req))
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
|
|
341
|
+
# gateway). Bearer via `config[:gateway_token]` (fail-closed). Translates
|
|
342
|
+
# the request -> :send_message and the turn's
|
|
343
|
+
# Event Stream -> OpenAI Responses SSE. Always streams (the consumer asks for SSE).
|
|
344
|
+
def handle_responses(req)
|
|
345
|
+
gate = gateway_gate(req)
|
|
346
|
+
return gate if gate
|
|
347
|
+
|
|
348
|
+
parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
|
|
349
|
+
tenant = req_tenant(req)
|
|
350
|
+
ensure_session(parsed[:user], tenant: tenant)
|
|
351
|
+
payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
|
|
352
|
+
payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
|
|
353
|
+
message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
|
|
354
|
+
tenant: tenant)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# POST /v1/agents — provisions (upserts) an agent from a standardized
|
|
358
|
+
# PACK. Same Bearer as /v1/responses (gateway_token,
|
|
359
|
+
# fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
|
|
360
|
+
# JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
|
|
361
|
+
# Raw body (string keys): the pack's file/skill names are data keys,
|
|
362
|
+
# not symbols.
|
|
363
|
+
def handle_provision(req)
|
|
364
|
+
gate = gateway_gate(req)
|
|
365
|
+
return gate if gate
|
|
366
|
+
|
|
367
|
+
pack = Insika::Pack.from_h(parse_raw_body(req))
|
|
368
|
+
json_response(200, @provisioner.import(pack)) # Validation/NotFound -> 422/404 in #call
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
|
|
372
|
+
# Same Bearer as provisioning (gateway_token, fail-
|
|
373
|
+
# closed): it's an authoring/provisioning surface and resolves the
|
|
374
|
+
# deployment's secrets. RAW body (string keys): the JSON Schema property names and
|
|
375
|
+
# the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
|
|
376
|
+
# report }. Structural manifest error -> 422 via the #call rescue; per-tool
|
|
377
|
+
# failure stays isolated in `errors[]` (R4). Dynamic base_url: the egress guard
|
|
378
|
+
# + host_allowlist block destinations outside the allowlist at CALL time (R5).
|
|
379
|
+
def handle_import_tools(req)
|
|
380
|
+
gate = gateway_gate(req)
|
|
381
|
+
return gate if gate
|
|
382
|
+
|
|
383
|
+
command = Insika::Command.build(:import_tools, parse_raw_body(req), transport: :http)
|
|
384
|
+
json_response(200, dispatch_with_timeout(command))
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
|
|
388
|
+
# Bearer as provisioning (gateway_token, fail-closed): it's an authoring
|
|
389
|
+
# surface. Discovers the tools of the MCP instance `:name` (via a client injectable
|
|
390
|
+
# at the composition root) and ingests them as data-tools (reuses :import_tools:
|
|
391
|
+
# upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
|
|
392
|
+
# + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
|
|
393
|
+
# failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
|
|
394
|
+
def handle_import_mcp_tools(req, name)
|
|
395
|
+
gate = gateway_gate(req)
|
|
396
|
+
return gate if gate
|
|
397
|
+
|
|
398
|
+
command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
|
|
399
|
+
json_response(200, dispatch_with_timeout(command))
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# DELETE /v1/agents/:id — removes the agent (delete_agent). NotFoundError
|
|
403
|
+
# (missing) -> 404 via the #call rescue.
|
|
404
|
+
def handle_deprovision(req, id)
|
|
405
|
+
gate = gateway_gate(req)
|
|
406
|
+
return gate if gate
|
|
407
|
+
|
|
408
|
+
json_response(200, @provisioner.delete(id))
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
|
|
412
|
+
# (a client: it never reads a store) can tell "this case cannot run here" from
|
|
413
|
+
# "this case failed". Deliberately NOT the profile: the prompt,
|
|
414
|
+
# the model and the guardrail config are none of the caller's business. Just the
|
|
415
|
+
# two facts a case declares `requires` against.
|
|
416
|
+
#
|
|
417
|
+
# `tools` is the DECLARED allowlist, and `null` means the agent has an open one
|
|
418
|
+
# (every registered tool) — the client reads that as "cannot rule anything out"
|
|
419
|
+
# and runs the case rather than skipping it.
|
|
420
|
+
def handle_read_agent(id)
|
|
421
|
+
profile = @profiles.fetch(id)
|
|
422
|
+
raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
|
|
423
|
+
|
|
424
|
+
allow = profile.tools_allow
|
|
425
|
+
deny = Array(profile.tools_deny).map(&:to_s)
|
|
426
|
+
json_response(200, {
|
|
427
|
+
id: profile.id,
|
|
428
|
+
tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
|
|
429
|
+
capabilities: Array(profile.capabilities_declared).map(&:to_s)
|
|
430
|
+
})
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
|
|
434
|
+
# shape is the platform's, so neither reads this header. Runs BEFORE the
|
|
435
|
+
# gateway gate: which version the caller asked for is a contract question,
|
|
436
|
+
# answerable regardless of whether the request is authorized. Absent header
|
|
437
|
+
# -> nil (current behaviour); an unknown value -> 400, not a silent fallback.
|
|
438
|
+
def version_gate(req)
|
|
439
|
+
version = req.get_header("HTTP_INSIKA_VERSION")
|
|
440
|
+
return nil if Coercion.blank?(version) || KNOWN_VERSIONS.include?(version)
|
|
441
|
+
|
|
442
|
+
error_response(400, Insika::ValidationError.new("unknown Insika-Version: #{version.inspect}"))
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
|
|
446
|
+
# ok (the handler proceeds). In multi_tenant mode the resolved principal
|
|
447
|
+
# is stashed on the request env: `tenant_principal?`/`req_tenant` read it
|
|
448
|
+
# back for the surface gate and the command stamping.
|
|
449
|
+
def gateway_gate(req)
|
|
450
|
+
result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
|
|
451
|
+
req.get_header("HTTP_AUTHORIZATION"))
|
|
452
|
+
case result
|
|
453
|
+
when :disabled then auth_error(503, "gateway disabled")
|
|
454
|
+
when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
|
|
455
|
+
else
|
|
456
|
+
req.set_header("insika.principal", result)
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# -> bool: is the requester a TENANT principal (multi_tenant mode only)?
|
|
462
|
+
# The surface gate and the session/task read gates consume it; an operator
|
|
463
|
+
# principal is NOT a tenant (it has the run of the deployment, exactly as
|
|
464
|
+
# in single_tenant mode).
|
|
465
|
+
def tenant_principal?(req)
|
|
466
|
+
p = req.get_header("insika.principal")
|
|
467
|
+
p && p[:role] == "tenant"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# The tenant the request operates AS: nil for an operator/classic request.
|
|
471
|
+
def req_tenant(req)
|
|
472
|
+
p = req.get_header("insika.principal")
|
|
473
|
+
p && p[:role] == "tenant" ? p[:tenant_id] : nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# A tenant may reach ONLY its own runtime surfaces. Everything else
|
|
477
|
+
# (commands, provisioning, authoring, config) is the operator's. An
|
|
478
|
+
# unknown route is NOT here -> a tenant is refused (the surface exists —
|
|
479
|
+
# just not for them), not told it is missing.
|
|
480
|
+
TENANT_SURFACES = [
|
|
481
|
+
["POST", ["v1", "sessions"]],
|
|
482
|
+
["POST", ["v1", "messages"]],
|
|
483
|
+
["POST", ["v1", "responses"]],
|
|
484
|
+
["POST", ["v1", "workflows", nil]],
|
|
485
|
+
["GET", ["v1", "workflows"]],
|
|
486
|
+
["GET", ["v1", "sessions", nil]],
|
|
487
|
+
["GET", ["v1", "tasks", nil]],
|
|
488
|
+
["GET", ["v1", "events"]]
|
|
489
|
+
].freeze
|
|
490
|
+
private_constant :TENANT_SURFACES
|
|
491
|
+
|
|
492
|
+
def tenant_surface?(method, segments)
|
|
493
|
+
TENANT_SURFACES.any? do |m, s|
|
|
494
|
+
m == method && s.zip(segments).all? { |pattern, got| pattern.nil? || pattern == got }
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Session id namespacing (WS1): a tenant's session lives under
|
|
499
|
+
# "<tenant>:<id>", so two tenants using the SAME chat id never share a
|
|
500
|
+
# session — the key itself is the isolation, not a convention. ":"
|
|
501
|
+
# (never "/") so the id stays one URL path segment; the same delimiter
|
|
502
|
+
# convention as the channels' "<channel>:<external_id>". Idempotent (a
|
|
503
|
+
# caller passing its own namespaced id back is not double-prefixed).
|
|
504
|
+
def scoped_session_id(tenant, id)
|
|
505
|
+
return id if tenant.nil? || id.nil? || id.to_s.empty?
|
|
506
|
+
return id if id.to_s.start_with?("#{tenant}:")
|
|
507
|
+
|
|
508
|
+
"#{tenant}:#{id}"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# POST /channels/:id/events — the Shape B inbound webhook.
|
|
512
|
+
# ACK FAST and never the reply: the platform (or the relay consumer) is holding
|
|
513
|
+
# a connection open with a retry timer on it, so this dispatches the turn and
|
|
514
|
+
# answers with its id. The answer itself leaves later, out of band, through the
|
|
515
|
+
# channel's own `deliver`.
|
|
516
|
+
#
|
|
517
|
+
# The channel does ALL the translating — auth, envelope, session correlation —
|
|
518
|
+
# and this handler stays what `server/` is allowed to be: a route that turns a
|
|
519
|
+
# request into a Command. Four answers, and each one is a different fact:
|
|
520
|
+
# 202 {task_id} a turn is running; its reply will be delivered
|
|
521
|
+
# 200 {task_id, duplicate} we already ran this event id
|
|
522
|
+
# 200 {task_id, merged} it joined a turn at the door
|
|
523
|
+
# 200 {task_id, steered} it was appended to a turn already running
|
|
524
|
+
# A consumer that treats the last three as 202 delivers the same answer twice.
|
|
525
|
+
def handle_channel_event(req, id)
|
|
526
|
+
channel = @channels.find(id)
|
|
527
|
+
return not_found if channel.nil?
|
|
528
|
+
|
|
529
|
+
gate = channel_gate(channel, req)
|
|
530
|
+
return gate if gate
|
|
531
|
+
|
|
532
|
+
parsed = channel.parse(req, body: parse_raw_body(req)) # ValidationError -> 422
|
|
533
|
+
session_id = channel.session_id_for(parsed[:external_id])
|
|
534
|
+
ensure_session(session_id, vars: session_vars(id, parsed))
|
|
535
|
+
|
|
536
|
+
payload = { agent: parsed[:agent], session_id: session_id,
|
|
537
|
+
message: parsed[:message], event_id: parsed[:event_id] }.compact
|
|
538
|
+
channel_ack(payload, transport: :"channel:#{id}")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
|
|
542
|
+
# channel. The engine issues the id and the client never
|
|
543
|
+
# proposes one: an endpoint that created a session from a caller-supplied id
|
|
544
|
+
# would let anyone read someone else's conversation by guessing.
|
|
545
|
+
#
|
|
546
|
+
# Only a channel that mints answers here — the relay's id is the consumer's own
|
|
547
|
+
# key, so `/sessions` does not exist for it (404, the same parity every other
|
|
548
|
+
# optional surface has).
|
|
549
|
+
def handle_channel_session(req, id)
|
|
550
|
+
channel = @channels.find(id)
|
|
551
|
+
return not_found if channel.nil? || !channel.respond_to?(:mint_session_id)
|
|
552
|
+
|
|
553
|
+
gate = channel_gate(channel, req)
|
|
554
|
+
return cors(channel, req, gate) if gate
|
|
555
|
+
|
|
556
|
+
session_id = channel.mint_session_id
|
|
557
|
+
ensure_session(session_id, vars: { "channel" => id.to_s })
|
|
558
|
+
cors(channel, req, json_response(201, { session_id: session_id }))
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# POST /channels/:id/messages — the Shape A turn: the reply comes back on THIS
|
|
562
|
+
# connection as SSE, so there is no outbox and nothing to deliver later. It is
|
|
563
|
+
# `handle_responses` with the hardcoded `Responses` module swapped for the
|
|
564
|
+
# looked-up channel, which is the whole point of naming the seam.
|
|
565
|
+
#
|
|
566
|
+
# The session must already exist AND belong to this channel. Both halves
|
|
567
|
+
# matter: create-on-write would reopen the enumeration hole `/sessions` closes,
|
|
568
|
+
# and skipping the ownership check would let a widget visitor stream a
|
|
569
|
+
# relay customer's conversation by pasting its id.
|
|
570
|
+
def handle_channel_message(req, id)
|
|
571
|
+
channel = @channels.find(id)
|
|
572
|
+
return not_found if channel.nil? || !channel.respond_to?(:frame_for)
|
|
573
|
+
|
|
574
|
+
gate = channel_gate(channel, req)
|
|
575
|
+
return cors(channel, req, gate) if gate
|
|
576
|
+
|
|
577
|
+
parsed = channel.parse(req, body: parse_raw_body(req))
|
|
578
|
+
return cors(channel, req, error_response(404, unknown_session)) unless channel_session?(id, parsed[:session_id])
|
|
579
|
+
|
|
580
|
+
payload = { agent: parsed[:agent], session_id: parsed[:session_id], message: parsed[:message] }
|
|
581
|
+
cors(channel, req, message_flow(payload, stream: true, transport: :"channel:#{id}",
|
|
582
|
+
serialize: channel.method(:frame_for)))
|
|
583
|
+
rescue Insika::ValidationError => e
|
|
584
|
+
# Answered here rather than through #call's rescue so the CORS headers ride
|
|
585
|
+
# along: without them the browser cannot read the 422 and the visitor sees a
|
|
586
|
+
# generic network failure instead of what was wrong.
|
|
587
|
+
cors(channel, req, error_response(422, e))
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# GET /channels/:id/asset/:f — the channel's static file (the widget's JS).
|
|
591
|
+
# The name is a KEY of the channel's own closed map, never a path, so there is
|
|
592
|
+
# no traversal to find. Public and unauthenticated by nature: it is a
|
|
593
|
+
# `<script src>` on someone else's page.
|
|
594
|
+
# The cache policy is short and the ETag does the rest: the URL carries no
|
|
595
|
+
# version (the install snippet an adopter pasted has none), so a long max-age
|
|
596
|
+
# would strand every browser on the widget it already has, while a revalidation
|
|
597
|
+
# that answers 304 costs one empty round trip and ships an upgrade in minutes.
|
|
598
|
+
def handle_channel_asset(req, id, file)
|
|
599
|
+
channel = @channels.find(id)
|
|
600
|
+
return not_found if channel.nil? || !channel.respond_to?(:asset)
|
|
601
|
+
|
|
602
|
+
asset = channel.asset(file)
|
|
603
|
+
return not_found if asset.nil?
|
|
604
|
+
|
|
605
|
+
headers = { "content-type" => asset[:content_type],
|
|
606
|
+
"cache-control" => asset[:cache_control] || "no-cache",
|
|
607
|
+
"etag" => asset[:etag] }.compact
|
|
608
|
+
return [304, headers, []] if asset[:etag] && req.get_header("HTTP_IF_NONE_MATCH") == asset[:etag]
|
|
609
|
+
|
|
610
|
+
[200, headers, [asset[:body]]]
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# OPTIONS /channels/:id/* — the CORS preflight. Answered BEFORE the channel's
|
|
614
|
+
# own check on purpose: a preflight carries no credentials (the browser strips
|
|
615
|
+
# them, by spec), so gating it would only mean the real request never happens.
|
|
616
|
+
# It grants nothing — an origin off the allowlist gets no headers back and the
|
|
617
|
+
# browser refuses the response itself.
|
|
618
|
+
def handle_channel_preflight(req, id)
|
|
619
|
+
channel = @channels.find(id)
|
|
620
|
+
return not_found if channel.nil?
|
|
621
|
+
|
|
622
|
+
cors(channel, req, [204, {}, []])
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Does this session exist AND belong to this channel? `vars["channel"]` is
|
|
626
|
+
# written when the session is minted.
|
|
627
|
+
def channel_session?(id, session_id)
|
|
628
|
+
session = session_id && @session_store.find(session_id)
|
|
629
|
+
return false if session.nil?
|
|
630
|
+
|
|
631
|
+
vars = session.vars || {}
|
|
632
|
+
(vars["channel"] || vars[:channel]).to_s == id.to_s
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def unknown_session = Insika::NotFoundError.new("session not found")
|
|
636
|
+
|
|
637
|
+
# Merges the channel's CORS headers into a response it is about to return. A
|
|
638
|
+
# channel with no opinion (the relay: its consumer is a server, not a browser)
|
|
639
|
+
# changes nothing.
|
|
640
|
+
def cors(channel, req, response)
|
|
641
|
+
return response unless channel.respond_to?(:cors_headers)
|
|
642
|
+
|
|
643
|
+
headers = channel.cors_headers(req.get_header("HTTP_ORIGIN"))
|
|
644
|
+
return response if headers.nil? || headers.empty?
|
|
645
|
+
|
|
646
|
+
status, existing, body = response
|
|
647
|
+
[status, existing.merge(headers), body]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# The channel's OWN credential check. A channel returns a verdict, not a status
|
|
651
|
+
# code — HTTP is this file's vocabulary, not lib/'s — and a channel that never
|
|
652
|
+
# implements one is refused rather than defaulted open: an unauthenticated
|
|
653
|
+
# public inbound route with an LLM behind it is a money faucet.
|
|
654
|
+
def channel_gate(channel, req)
|
|
655
|
+
verdict = channel.respond_to?(:authenticate) ? channel.authenticate(req) : :disabled
|
|
656
|
+
case verdict
|
|
657
|
+
when :disabled then auth_error(503, "channel disabled")
|
|
658
|
+
when :unauthorized then auth_error(401, "unauthorized", "www-authenticate" => "Bearer")
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Dispatch + ack, with NO subscription: nothing about this request waits for the
|
|
663
|
+
# turn. That is the difference between Shape B and every other surface here.
|
|
664
|
+
def channel_ack(payload, transport:)
|
|
665
|
+
result = @command_bus.dispatch(Insika::Command.build(:send_message, payload, transport: transport))
|
|
666
|
+
verdict = %i[duplicate merged steered].find { |k| result[k] }
|
|
667
|
+
return json_response(200, { task_id: result[:task_id], verdict => true }) if verdict
|
|
668
|
+
|
|
669
|
+
json_response(202, { task_id: result[:task_id] })
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# `channel` + `external_id` on the session are how a later turn
|
|
673
|
+
# (and the outbox) know where a reply goes. The consumer's own `vars` ride along
|
|
674
|
+
# on first contact, but never over those two — a caller must not be able to
|
|
675
|
+
# rewrite its own conversation's address.
|
|
676
|
+
def session_vars(channel_id, parsed)
|
|
677
|
+
(parsed[:vars] || {}).merge("channel" => channel_id.to_s,
|
|
678
|
+
"external_id" => parsed[:external_id].to_s)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Session correlated by an explicit id (`user`=chat.id on /v1/responses, the
|
|
682
|
+
# namespaced `<channel>:<external_id>` for a channel): creates if new, continues
|
|
683
|
+
# if it exists (multi-turn). Via Command (server/ does not write to a store).
|
|
684
|
+
# Benign race (two near-simultaneous turns creating) -> ArgumentError from the
|
|
685
|
+
# store, treated as "already exists". A TENANT's id is namespaced (WS1), so
|
|
686
|
+
# two tenants with the same chat id get two isolated sessions.
|
|
687
|
+
def ensure_session(id, vars: { channel: "responses" }, tenant: nil)
|
|
688
|
+
id = scoped_session_id(tenant, id)
|
|
689
|
+
return if @session_store.find(id)
|
|
690
|
+
|
|
691
|
+
@command_bus.dispatch(
|
|
692
|
+
Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
|
|
693
|
+
tenant: tenant)
|
|
694
|
+
)
|
|
695
|
+
rescue ArgumentError
|
|
696
|
+
nil
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# GET /v1/sessions/:id — direct read (not a Command). A tenant may only
|
|
700
|
+
# read its OWN sessions: the ownership is the id namespace itself (its
|
|
701
|
+
# sessions live under "<tenant>:…"), anything else reads as a 404.
|
|
702
|
+
def handle_read_session(req, id)
|
|
703
|
+
tenant = req_tenant(req)
|
|
704
|
+
if tenant && !id.to_s.start_with?("#{tenant}:")
|
|
705
|
+
raise Insika::NotFoundError, "session not found: #{id}"
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
session = @session_store.find(id)
|
|
709
|
+
raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
|
|
710
|
+
|
|
711
|
+
json_response(200, { session: session.to_h })
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# GET /v1/tasks/:id — direct read. This is where the consumer observes
|
|
715
|
+
# PolicyDenied/post-202 failures: the terminal state lives in the Task
|
|
716
|
+
# Store; nothing is lost if the client disconnected. A tenant may only
|
|
717
|
+
# read a task its own command stamped (the task record carries the
|
|
718
|
+
# command with its meta.tenant) — someone else's reads as a 404.
|
|
719
|
+
def handle_read_task(req, id)
|
|
720
|
+
task = @task_store.find(id)
|
|
721
|
+
raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
|
|
722
|
+
|
|
723
|
+
tenant = req_tenant(req)
|
|
724
|
+
if tenant && task_tenant(task) != tenant
|
|
725
|
+
raise Insika::NotFoundError, "task not found: #{id}"
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
body = { task: task_to_h(task) }
|
|
729
|
+
# pending approvals: this is where the consumer/operator sees
|
|
730
|
+
# what needs approval after an :approval_requested.
|
|
731
|
+
if @pending_action_store
|
|
732
|
+
body[:pending_actions] = @pending_action_store.open_for(id).map(&:to_h)
|
|
733
|
+
end
|
|
734
|
+
json_response(200, body)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# The tenant stamped on the task's persisted command (WS1): string or
|
|
738
|
+
# symbol keys, whichever the store round-trip produced.
|
|
739
|
+
def task_tenant(task)
|
|
740
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
741
|
+
return nil unless command.is_a?(Hash)
|
|
742
|
+
|
|
743
|
+
meta = command["meta"] || command[:meta] || {}
|
|
744
|
+
meta["tenant"] || meta[:tenant]
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# GET /v1/events?task_id=&session_id= — here the filters ARE known.
|
|
748
|
+
# CONTINUOUS stream (post-crash reconnection route): does not close on a
|
|
749
|
+
# terminal event — ends on client disconnect or cap. A tenant's stream is
|
|
750
|
+
# scoped to its own events (fail-closed on the event's meta.tenant).
|
|
751
|
+
def handle_events(req)
|
|
752
|
+
subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
|
|
753
|
+
session_id: req.GET["session_id"],
|
|
754
|
+
tenant: req_tenant(req))
|
|
755
|
+
sse_response(subscription)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# POST /a2a — JSON-RPC 2.0: HTTP 200 ALWAYS (the error travels in the envelope,
|
|
759
|
+
# not in the status). Malformed JSON -> -32700 (A2A envelope, not the generic
|
|
760
|
+
# HTTP error of #call). The A2A::App never leaks an exception. Parse with STRING
|
|
761
|
+
# keys (the A2A wire is generic JSON — does NOT reuse `parse_body`, which
|
|
762
|
+
# symbolizes for Command payloads).
|
|
763
|
+
def handle_a2a(req)
|
|
764
|
+
raw = req.body&.read
|
|
765
|
+
body =
|
|
766
|
+
begin
|
|
767
|
+
raw.nil? || raw.empty? ? {} : JSON.parse(raw)
|
|
768
|
+
rescue StandardError
|
|
769
|
+
return json_response(200, A2A::Protocol.error(nil, A2A::Errors::PARSE_ERROR, "parse error"))
|
|
770
|
+
end
|
|
771
|
+
json_response(200, @a2a.rpc(body))
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# --- Turn flow (SSE or aggregated) ------------------------------------
|
|
775
|
+
|
|
776
|
+
# Subscribe BEFORE dispatching: under Async the task fiber may
|
|
777
|
+
# run eagerly and emit :task_started before dispatch returns. The
|
|
778
|
+
# task_id only exists AFTER dispatch -> subscribe WITHOUT a filter and filter in the
|
|
779
|
+
# transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
|
|
780
|
+
# happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
|
|
781
|
+
# #call rescue (becomes an HTTP status).
|
|
782
|
+
def message_flow(payload, stream:, serialize: nil, transport: :http, tenant: nil)
|
|
783
|
+
# WS1: a tenant's session_id is NAMESPACED before the command is built,
|
|
784
|
+
# so the turn lands on the tenant's OWN session even when another tenant
|
|
785
|
+
# uses the same chat id. The payload the caller sent is untouched.
|
|
786
|
+
if tenant
|
|
787
|
+
payload = payload.dup
|
|
788
|
+
payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
|
|
789
|
+
end
|
|
790
|
+
command = Insika::Command.build(:send_message, payload, transport: transport, tenant: tenant)
|
|
791
|
+
subscription = @event_stream.subscribe
|
|
792
|
+
result =
|
|
793
|
+
begin
|
|
794
|
+
@command_bus.dispatch(command)
|
|
795
|
+
rescue StandardError
|
|
796
|
+
subscription.close
|
|
797
|
+
raise
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# the message joined another turn — one still waiting at the
|
|
801
|
+
# door (`merged`) or one already running (`steered`). Either way this call
|
|
802
|
+
# owns no reply; the one holding `task_id` does. Say exactly that and open no
|
|
803
|
+
# stream: a caller that delivered this response's (empty) output would
|
|
804
|
+
# duplicate the answer.
|
|
805
|
+
if result[:merged] || result[:steered]
|
|
806
|
+
subscription.close
|
|
807
|
+
verdict = result[:merged] ? :merged : :steered
|
|
808
|
+
return json_response(200, { task_id: result[:task_id], verdict => true })
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
task_id = result[:task_id]
|
|
812
|
+
# Bind the subscription to the task_id now that it exists: the cap now
|
|
813
|
+
# counts only events for THIS task and the overflow :error goes out with the
|
|
814
|
+
# right task_id. The already-enqueued events (eager fiber) belong to this task —
|
|
815
|
+
# none is lost.
|
|
816
|
+
subscription.bind(task_id: task_id)
|
|
817
|
+
filtered = TaskFilter.new(subscription, task_id)
|
|
818
|
+
stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Workflow trigger flow. Async by default (the honest workflow
|
|
822
|
+
# contract: fire the run, return the runId); ?stream=true streams the run's
|
|
823
|
+
# events like a turn. Same subscribe-before-dispatch discipline as
|
|
824
|
+
# message_flow so no eager event is lost when streaming. A synchronous handler
|
|
825
|
+
# error (bad input / unknown workflow) closes the subscription and propagates
|
|
826
|
+
# to #call (HTTP status).
|
|
827
|
+
def workflow_flow(payload, stream:, tenant: nil)
|
|
828
|
+
# WS1: same session-id namespacing as message_flow — the workflow's run
|
|
829
|
+
# session is the tenant's own.
|
|
830
|
+
if tenant
|
|
831
|
+
payload = payload.dup
|
|
832
|
+
payload[:session_id] = scoped_session_id(tenant, payload[:session_id]) if payload[:session_id]
|
|
833
|
+
end
|
|
834
|
+
command = Insika::Command.build(:trigger_workflow, payload, transport: :http, tenant: tenant)
|
|
835
|
+
|
|
836
|
+
unless stream
|
|
837
|
+
result = dispatch_with_timeout(command)
|
|
838
|
+
return json_response(202, { run_id: result[:run_id] || result[:task_id], task_id: result[:task_id] })
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
subscription = @event_stream.subscribe
|
|
842
|
+
result =
|
|
843
|
+
begin
|
|
844
|
+
@command_bus.dispatch(command)
|
|
845
|
+
rescue StandardError
|
|
846
|
+
subscription.close
|
|
847
|
+
raise
|
|
848
|
+
end
|
|
849
|
+
task_id = result[:task_id]
|
|
850
|
+
subscription.bind(task_id: task_id)
|
|
851
|
+
sse_response(TaskFilter.new(subscription, task_id))
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# stream=false: aggregates by iterating the filtered subscription in the
|
|
855
|
+
# request's own fiber. Accumulates :content deltas; responds at the
|
|
856
|
+
# terminal. The `error:` shape mirrors the :task_failed data (smallest
|
|
857
|
+
# coherent extension — the state is also in GET /v1/tasks/:id). Non-happy
|
|
858
|
+
# terminals (:task_cancelled, overflow :error) also become `error:` —
|
|
859
|
+
# a cancelled/truncated turn is NEVER reported as a 200 success.
|
|
860
|
+
def aggregate_response(subscription, task_id)
|
|
861
|
+
content = +""
|
|
862
|
+
events = []
|
|
863
|
+
error = nil
|
|
864
|
+
|
|
865
|
+
subscription.each do |event|
|
|
866
|
+
events << event.to_h
|
|
867
|
+
case event.type
|
|
868
|
+
when :content then content << event.data[:delta].to_s
|
|
869
|
+
when :task_failed
|
|
870
|
+
error = { class: event.data[:error], message: event.data[:message] }
|
|
871
|
+
# A8: the classification rides through when the executor wrapped
|
|
872
|
+
# the failure (ProviderError) — additive for every other terminal.
|
|
873
|
+
error = error.merge(event.data.slice(:kind, :retryable, :retry_after))
|
|
874
|
+
when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
|
|
875
|
+
when :error then error ||= { class: nil, message: event.data[:message] }
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
if error
|
|
880
|
+
json_response(200, { task_id: task_id, events: events, error: error })
|
|
881
|
+
else
|
|
882
|
+
json_response(200, { content: content, task_id: task_id, events: events })
|
|
883
|
+
end
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def sse_response(subscription, serialize: nil)
|
|
887
|
+
[200, SSE_HEADERS.dup, SSEBody.new(subscription: subscription, heartbeat: @heartbeat, serialize: serialize)]
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# --- Dispatch and serialization --------------------------------------
|
|
891
|
+
|
|
892
|
+
# Control Commands may exceed 10s -> 504. For
|
|
893
|
+
# turn Commands the dispatch returns immediately (the turn lives in the fiber) —
|
|
894
|
+
# the timeout is harmless. NEVER Timeout.timeout from the stdlib. With no current
|
|
895
|
+
# reactor (pure control test), dispatches directly.
|
|
896
|
+
def dispatch_with_timeout(command)
|
|
897
|
+
task = Async::Task.current?
|
|
898
|
+
return @command_bus.dispatch(command) if task.nil?
|
|
899
|
+
|
|
900
|
+
task.with_timeout(@sync_timeout) { @command_bus.dispatch(command) }
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Turn -> {task_id:} -> 202. Any other shape (control:
|
|
904
|
+
# Session/Task, which are Data) -> 200 with serialized to_h.
|
|
905
|
+
def command_response(result)
|
|
906
|
+
if turn_result?(result)
|
|
907
|
+
json_response(202, { task_id: result[:task_id] })
|
|
908
|
+
else
|
|
909
|
+
json_response(200, result.to_h)
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Turn = Hash with task_id PRESENT and non-nil. A control that
|
|
914
|
+
# returned a Hash without a useful task_id is not mistaken for a turn.
|
|
915
|
+
def turn_result?(result)
|
|
916
|
+
result.is_a?(Hash) && !result[:task_id].nil?
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# Empty body or no content-type -> {} (transport validates only
|
|
920
|
+
# well-formed JSON; the payload belongs to the handler). Does NOT use req.params (it would
|
|
921
|
+
# consume the body as a form) — reads the raw body.
|
|
922
|
+
def parse_body(req)
|
|
923
|
+
raw = req.body&.read
|
|
924
|
+
return {} if raw.nil? || raw.empty?
|
|
925
|
+
|
|
926
|
+
JSON.parse(raw, symbolize_names: true)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Like parse_body, but keeps STRING keys: for payloads with arbitrary
|
|
930
|
+
# DATA keys (a pack's file/skill names), which must not become
|
|
931
|
+
# symbols. Malformed JSON -> JSON::ParserError (#call maps it to 400).
|
|
932
|
+
def parse_raw_body(req)
|
|
933
|
+
raw = req.body&.read
|
|
934
|
+
return {} if raw.nil? || raw.empty?
|
|
935
|
+
|
|
936
|
+
JSON.parse(raw)
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
# Task#to_h is shallow (Data#to_h doesn't recurse): `executions` stays as an Array of
|
|
940
|
+
# Execution (Data), which JSON.generate would serialize as an opaque string
|
|
941
|
+
# (`"#<data ...>"`) — unreadable for the consumer observing failures via
|
|
942
|
+
# GET /v1/tasks/:id. Recurses the Executions serialization.
|
|
943
|
+
def task_to_h(task)
|
|
944
|
+
task.to_h.merge(executions: task.executions.map(&:to_h))
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def json_response(status, body)
|
|
948
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Raw markdown (start.md / a public doc). charset is explicit so a coding agent
|
|
952
|
+
# fetching over HTTP decodes accents correctly.
|
|
953
|
+
def markdown_response(status, text)
|
|
954
|
+
[status, { "content-type" => "text/markdown; charset=utf-8" }, [text]]
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def error_response(status, error)
|
|
958
|
+
json_response(status, { error: { class: error.class.name, message: error.message } })
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# the 500 is the ONE status whose body carries the retry envelope —
|
|
962
|
+
# retryable/retry_after tell the client what to do, error_ref is what it
|
|
963
|
+
# quotes when the retry keeps failing. The SAME ref is logged here, or the
|
|
964
|
+
# field is decoration. 4xx stay bare: a client error is fixed by editing
|
|
965
|
+
# the request, not by waiting.
|
|
966
|
+
def internal_error_response(error)
|
|
967
|
+
ref = "err_#{SecureRandom.hex(8)}"
|
|
968
|
+
# Observability only (shutdown.rb's rule): a logger failure must never
|
|
969
|
+
# mask the 500 the client is owed.
|
|
970
|
+
begin
|
|
971
|
+
@logger&.puts("[server] #{ref} #{error.class}: #{error.message}\n" \
|
|
972
|
+
"#{Array(error.backtrace).first(5).join("\n")}")
|
|
973
|
+
rescue StandardError
|
|
974
|
+
nil
|
|
975
|
+
end
|
|
976
|
+
# B9/A8: a classified ProviderError quotes its own retry guidance;
|
|
977
|
+
# everything else keeps the blanket retry (the caller may have died mid
|
|
978
|
+
# request — a bounded wait is the safe default).
|
|
979
|
+
retryable = error.respond_to?(:retryable) && !error.retryable.nil? ? error.retryable : true
|
|
980
|
+
retry_after = error.respond_to?(:retry_after) && error.retry_after ? error.retry_after : RETRY_AFTER_SECONDS
|
|
981
|
+
body = { error: { class: error.class.name, message: error.message,
|
|
982
|
+
retryable: retryable, retry_after: retry_after, error_ref: ref } }
|
|
983
|
+
body[:error][:kind] = error.kind if error.respond_to?(:kind) && error.kind
|
|
984
|
+
json_response(500, body)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def not_found
|
|
988
|
+
[404, { "content-type" => "text/plain" }, ["not found"]]
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
|
|
992
|
+
# has already run (Boot only returns the app afterward), it's ready. Does NOT
|
|
993
|
+
# touch a store (health cannot fail on IO nor require auth).
|
|
994
|
+
def health = json_response(200, { status: "ok" })
|
|
995
|
+
|
|
996
|
+
# Thin Subscription decorator: discards
|
|
997
|
+
# events from OTHER tasks and CLOSES the subscription after forwarding the task's
|
|
998
|
+
# terminal event. Solves the subscribe-before-task_id gap without touching
|
|
999
|
+
# the Subscription's signature.
|
|
1000
|
+
class TaskFilter
|
|
1001
|
+
def initialize(subscription, task_id)
|
|
1002
|
+
@subscription = subscription
|
|
1003
|
+
@task_id = task_id
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def each
|
|
1007
|
+
@subscription.each do |event|
|
|
1008
|
+
next unless (event.meta || {})[:task_id] == @task_id
|
|
1009
|
+
|
|
1010
|
+
yield event
|
|
1011
|
+
break if TERMINAL_EVENTS.include?(event.type)
|
|
1012
|
+
end
|
|
1013
|
+
ensure
|
|
1014
|
+
@subscription.close
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def close = @subscription.close
|
|
1018
|
+
end
|
|
1019
|
+
private_constant :TaskFilter
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
end
|