insika 0.1.0 → 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 +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- 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/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/resolve_refinement.rb +1 -1
- 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 +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- 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/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- 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 +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- 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 +3 -3
- 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/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- 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/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- 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/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/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- 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 +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- 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/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- 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 +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- 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 +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- 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/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -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,20 @@ 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)
|
|
51
59
|
@command_bus = command_bus
|
|
52
60
|
@event_stream = event_stream
|
|
53
61
|
@session_store = session_store
|
|
@@ -56,26 +64,37 @@ module Insika
|
|
|
56
64
|
@pending_action_store = pending_action_store # read for GET /v1/tasks/:id
|
|
57
65
|
@a2a = a2a # A2A edge. nil = server does not expose A2A (parity).
|
|
58
66
|
@provisioner = provisioner # PackImporter. nil = provisioning not exposed.
|
|
59
|
-
#
|
|
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
|
|
60
75
|
# exposed (the minimal wiring). nil = no /v1/workflows routes (parity — the
|
|
61
76
|
# deployment does not expose workflows). Reading a catalog is a READ, like a
|
|
62
77
|
# store read: the constitutional rule (no Executor/store-writes/RubyLLM) holds.
|
|
63
78
|
@workflow_registry = workflow_registry
|
|
64
|
-
#
|
|
79
|
+
# LLM-first onboarding surface (start.md + models.json +
|
|
65
80
|
# docs). PUBLIC (no auth — the whole point of the "read <base>/start.md" trick
|
|
66
81
|
# is that the developer's coding agent can fetch it), and READ-ONLY, so the
|
|
67
82
|
# constitutional rule holds. nil = routes not exposed (parity — the production
|
|
68
83
|
# deployment opts in). Reading files/masked stores is a READ, like a store read.
|
|
69
84
|
@onboarding = onboarding
|
|
70
|
-
#
|
|
85
|
+
# READ-ONLY ProfileSource, so `GET /v1/agents/:id` can answer
|
|
71
86
|
# what an agent has — the eval is a client and cannot read a store. Same
|
|
72
87
|
# constitutional footing as the workflow registry: reading a catalog is a
|
|
73
88
|
# READ. nil = the route 404s (parity).
|
|
74
89
|
@profiles = profiles
|
|
75
|
-
#
|
|
90
|
+
# ONE generic route family for every channel, opt-in by
|
|
76
91
|
# injecting the registry (nil ⇒ the routes do not exist, parity with @a2a).
|
|
77
92
|
# The channel does the translating; this class keeps doing only transport.
|
|
78
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
|
|
79
98
|
@heartbeat = config.fetch(:heartbeat, 15)
|
|
80
99
|
@sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
|
|
81
100
|
end
|
|
@@ -96,7 +115,7 @@ module Insika
|
|
|
96
115
|
rescue Async::TimeoutError => e
|
|
97
116
|
error_response(504, e) # synchronous control request exceeded the ceiling
|
|
98
117
|
rescue StandardError => e
|
|
99
|
-
|
|
118
|
+
internal_error_response(e)
|
|
100
119
|
end
|
|
101
120
|
|
|
102
121
|
private
|
|
@@ -114,8 +133,15 @@ module Insika
|
|
|
114
133
|
return version_error if version_error
|
|
115
134
|
end
|
|
116
135
|
|
|
117
|
-
|
|
136
|
+
gate = public_route?(req.request_method, segments) ? nil : gateway_gate(req)
|
|
118
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
|
|
119
145
|
|
|
120
146
|
case [req.request_method, segments]
|
|
121
147
|
in ["GET", ["up"]]
|
|
@@ -151,9 +177,9 @@ module Insika
|
|
|
151
177
|
in ["GET", ["v1", "agents", id]] if @profiles
|
|
152
178
|
handle_read_agent(id)
|
|
153
179
|
in ["GET", ["v1", "sessions", id]]
|
|
154
|
-
handle_read_session(id)
|
|
180
|
+
handle_read_session(req, id)
|
|
155
181
|
in ["GET", ["v1", "tasks", id]]
|
|
156
|
-
handle_read_task(id)
|
|
182
|
+
handle_read_task(req, id)
|
|
157
183
|
in ["GET", ["v1", "events"]]
|
|
158
184
|
handle_events(req)
|
|
159
185
|
in ["POST", ["channels", id, "events"]] if @channels
|
|
@@ -241,8 +267,21 @@ module Insika
|
|
|
241
267
|
# POST /v1/sessions — sugar for create_session; 201 {session}.
|
|
242
268
|
def handle_create_session(req)
|
|
243
269
|
body = parse_body(req)
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
246
285
|
session = dispatch_with_timeout(command)
|
|
247
286
|
json_response(201, { session: session.to_h })
|
|
248
287
|
end
|
|
@@ -251,13 +290,14 @@ module Insika
|
|
|
251
290
|
# "false" -> 200 JSON aggregated at the terminal event.
|
|
252
291
|
def handle_send_message(req)
|
|
253
292
|
stream = req.GET["stream"] != "false"
|
|
254
|
-
#
|
|
293
|
+
# only the aggregated-JSON form has room for the `merged`/`steered`
|
|
255
294
|
# verdict, so only it may join a message to another turn. Once the stream is open
|
|
256
295
|
# 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"
|
|
296
|
+
message_flow(parse_body(req), stream: stream, transport: stream ? :http : :"http:json",
|
|
297
|
+
tenant: req_tenant(req))
|
|
258
298
|
end
|
|
259
299
|
|
|
260
|
-
# GET /docs/:name.md — one public doc as raw markdown
|
|
300
|
+
# GET /docs/:name.md — one public doc as raw markdown. The
|
|
261
301
|
# slug is a KEY of the onboarding allowlist, so no filesystem traversal is
|
|
262
302
|
# possible; an unknown slug -> 404. `file` still carries the ".md" suffix.
|
|
263
303
|
def handle_doc(file)
|
|
@@ -274,7 +314,7 @@ module Insika
|
|
|
274
314
|
Insika::Coercion.presence(@config[:public_url]) || req.base_url
|
|
275
315
|
end
|
|
276
316
|
|
|
277
|
-
# GET /v1/workflows — discovery
|
|
317
|
+
# GET /v1/workflows — discovery. Direct read of the
|
|
278
318
|
# registry catalog (name + description + the I/O schema contract). Not a
|
|
279
319
|
# Command; opt-in via the injected registry.
|
|
280
320
|
def handle_list_workflows
|
|
@@ -293,7 +333,8 @@ module Insika
|
|
|
293
333
|
body = parse_body(req)
|
|
294
334
|
payload = { workflow: name, agent: body[:agent],
|
|
295
335
|
input: body[:input], session_id: body[:session_id] }.compact
|
|
296
|
-
workflow_flow(payload, stream: req.GET["stream"] == "true"
|
|
336
|
+
workflow_flow(payload, stream: req.GET["stream"] == "true",
|
|
337
|
+
tenant: req_tenant(req))
|
|
297
338
|
end
|
|
298
339
|
|
|
299
340
|
# POST /v1/responses — OpenAI Responses adapter (drop-in for the OpenClaw
|
|
@@ -305,14 +346,16 @@ module Insika
|
|
|
305
346
|
return gate if gate
|
|
306
347
|
|
|
307
348
|
parsed = Responses.parse_request(parse_body(req), req) # ValidationError -> 422
|
|
308
|
-
|
|
349
|
+
tenant = req_tenant(req)
|
|
350
|
+
ensure_session(parsed[:user], tenant: tenant)
|
|
309
351
|
payload = { agent: parsed[:agent], session_id: parsed[:user], message: parsed[:message] }
|
|
310
352
|
payload[:origin] = parsed[:origin] if parsed[:origin] # declared, else absent
|
|
311
|
-
message_flow(payload, stream: true, serialize: Responses.method(:frame_for)
|
|
353
|
+
message_flow(payload, stream: true, serialize: Responses.method(:frame_for),
|
|
354
|
+
tenant: tenant)
|
|
312
355
|
end
|
|
313
356
|
|
|
314
357
|
# POST /v1/agents — provisions (upserts) an agent from a standardized
|
|
315
|
-
# PACK
|
|
358
|
+
# PACK. Same Bearer as /v1/responses (gateway_token,
|
|
316
359
|
# fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
|
|
317
360
|
# JSON; the PackImporter emits the authoring Commands. -> 200 { summary }.
|
|
318
361
|
# Raw body (string keys): the pack's file/skill names are data keys,
|
|
@@ -326,7 +369,7 @@ module Insika
|
|
|
326
369
|
end
|
|
327
370
|
|
|
328
371
|
# POST /v1/tools/manifest — BATCH ingestion of data-tools via manifest
|
|
329
|
-
#
|
|
372
|
+
# Same Bearer as provisioning (gateway_token, fail-
|
|
330
373
|
# closed): it's an authoring/provisioning surface and resolves the
|
|
331
374
|
# deployment's secrets. RAW body (string keys): the JSON Schema property names and
|
|
332
375
|
# the headers are DATA, not symbols. Dispatches :import_tools -> 200 { per-tool
|
|
@@ -341,7 +384,7 @@ module Insika
|
|
|
341
384
|
json_response(200, dispatch_with_timeout(command))
|
|
342
385
|
end
|
|
343
386
|
|
|
344
|
-
# POST /v1/mcp/:name/import — LIVE MCP ingestion
|
|
387
|
+
# POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
|
|
345
388
|
# Bearer as provisioning (gateway_token, fail-closed): it's an authoring
|
|
346
389
|
# surface. Discovers the tools of the MCP instance `:name` (via a client injectable
|
|
347
390
|
# at the composition root) and ingests them as data-tools (reuses :import_tools:
|
|
@@ -367,7 +410,7 @@ module Insika
|
|
|
367
410
|
|
|
368
411
|
# GET /v1/agents/:id — what this deployment HAS for that agent, so an eval
|
|
369
412
|
# (a client: it never reads a store) can tell "this case cannot run here" from
|
|
370
|
-
# "this case failed"
|
|
413
|
+
# "this case failed". Deliberately NOT the profile: the prompt,
|
|
371
414
|
# the model and the guardrail config are none of the caller's business. Just the
|
|
372
415
|
# two facts a case declares `requires` against.
|
|
373
416
|
#
|
|
@@ -400,26 +443,83 @@ module Insika
|
|
|
400
443
|
end
|
|
401
444
|
|
|
402
445
|
# Gateway Bearer (fail-closed). -> error response (503/401) OR nil when
|
|
403
|
-
# ok (the handler proceeds).
|
|
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.
|
|
404
449
|
def gateway_gate(req)
|
|
405
|
-
|
|
450
|
+
result = Insika::Server::TenantAuth.check(@config[:gateway_token], @token_store,
|
|
451
|
+
req.get_header("HTTP_AUTHORIZATION"))
|
|
452
|
+
case result
|
|
406
453
|
when :disabled then auth_error(503, "gateway disabled")
|
|
407
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 }
|
|
408
495
|
end
|
|
409
496
|
end
|
|
410
497
|
|
|
411
|
-
#
|
|
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.
|
|
412
512
|
# ACK FAST and never the reply: the platform (or the relay consumer) is holding
|
|
413
513
|
# a connection open with a retry timer on it, so this dispatches the turn and
|
|
414
514
|
# answers with its id. The answer itself leaves later, out of band, through the
|
|
415
|
-
# channel's own `deliver
|
|
515
|
+
# channel's own `deliver`.
|
|
416
516
|
#
|
|
417
517
|
# The channel does ALL the translating — auth, envelope, session correlation —
|
|
418
518
|
# and this handler stays what `server/` is allowed to be: a route that turns a
|
|
419
519
|
# request into a Command. Four answers, and each one is a different fact:
|
|
420
520
|
# 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
|
|
521
|
+
# 200 {task_id, duplicate} we already ran this event id
|
|
522
|
+
# 200 {task_id, merged} it joined a turn at the door
|
|
423
523
|
# 200 {task_id, steered} it was appended to a turn already running
|
|
424
524
|
# A consumer that treats the last three as 202 delivers the same answer twice.
|
|
425
525
|
def handle_channel_event(req, id)
|
|
@@ -439,7 +539,7 @@ module Insika
|
|
|
439
539
|
end
|
|
440
540
|
|
|
441
541
|
# POST /channels/:id/sessions — mint a conversation for a PUBLIC Shape A
|
|
442
|
-
# channel
|
|
542
|
+
# channel. The engine issues the id and the client never
|
|
443
543
|
# proposes one: an endpoint that created a session from a caller-supplied id
|
|
444
544
|
# would let anyone read someone else's conversation by guessing.
|
|
445
545
|
#
|
|
@@ -523,7 +623,7 @@ module Insika
|
|
|
523
623
|
end
|
|
524
624
|
|
|
525
625
|
# Does this session exist AND belong to this channel? `vars["channel"]` is
|
|
526
|
-
# written when the session is minted
|
|
626
|
+
# written when the session is minted.
|
|
527
627
|
def channel_session?(id, session_id)
|
|
528
628
|
session = session_id && @session_store.find(session_id)
|
|
529
629
|
return false if session.nil?
|
|
@@ -569,7 +669,7 @@ module Insika
|
|
|
569
669
|
json_response(202, { task_id: result[:task_id] })
|
|
570
670
|
end
|
|
571
671
|
|
|
572
|
-
#
|
|
672
|
+
# `channel` + `external_id` on the session are how a later turn
|
|
573
673
|
# (and the outbox) know where a reply goes. The consumer's own `vars` ride along
|
|
574
674
|
# on first contact, but never over those two — a caller must not be able to
|
|
575
675
|
# rewrite its own conversation's address.
|
|
@@ -582,19 +682,29 @@ module Insika
|
|
|
582
682
|
# namespaced `<channel>:<external_id>` for a channel): creates if new, continues
|
|
583
683
|
# if it exists (multi-turn). Via Command (server/ does not write to a store).
|
|
584
684
|
# Benign race (two near-simultaneous turns creating) -> ArgumentError from the
|
|
585
|
-
# store, treated as "already exists".
|
|
586
|
-
|
|
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)
|
|
587
689
|
return if @session_store.find(id)
|
|
588
690
|
|
|
589
691
|
@command_bus.dispatch(
|
|
590
|
-
Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http
|
|
692
|
+
Insika::Command.build(:create_session, { id: id, vars: vars }, transport: :http,
|
|
693
|
+
tenant: tenant)
|
|
591
694
|
)
|
|
592
695
|
rescue ArgumentError
|
|
593
696
|
nil
|
|
594
697
|
end
|
|
595
698
|
|
|
596
|
-
# GET /v1/sessions/:id — direct read (not a Command).
|
|
597
|
-
|
|
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
|
+
|
|
598
708
|
session = @session_store.find(id)
|
|
599
709
|
raise Insika::NotFoundError, "session not found: #{id}" if session.nil?
|
|
600
710
|
|
|
@@ -603,11 +713,18 @@ module Insika
|
|
|
603
713
|
|
|
604
714
|
# GET /v1/tasks/:id — direct read. This is where the consumer observes
|
|
605
715
|
# PolicyDenied/post-202 failures: the terminal state lives in the Task
|
|
606
|
-
# Store; nothing is lost if the client disconnected.
|
|
607
|
-
|
|
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)
|
|
608
720
|
task = @task_store.find(id)
|
|
609
721
|
raise Insika::NotFoundError, "task not found: #{id}" if task.nil?
|
|
610
722
|
|
|
723
|
+
tenant = req_tenant(req)
|
|
724
|
+
if tenant && task_tenant(task) != tenant
|
|
725
|
+
raise Insika::NotFoundError, "task not found: #{id}"
|
|
726
|
+
end
|
|
727
|
+
|
|
611
728
|
body = { task: task_to_h(task) }
|
|
612
729
|
# pending approvals: this is where the consumer/operator sees
|
|
613
730
|
# what needs approval after an :approval_requested.
|
|
@@ -617,12 +734,24 @@ module Insika
|
|
|
617
734
|
json_response(200, body)
|
|
618
735
|
end
|
|
619
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
|
+
|
|
620
747
|
# GET /v1/events?task_id=&session_id= — here the filters ARE known.
|
|
621
748
|
# CONTINUOUS stream (post-crash reconnection route): does not close on a
|
|
622
|
-
# terminal event — ends on client disconnect or cap.
|
|
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).
|
|
623
751
|
def handle_events(req)
|
|
624
752
|
subscription = @event_stream.subscribe(task_id: req.GET["task_id"],
|
|
625
|
-
session_id: req.GET["session_id"]
|
|
753
|
+
session_id: req.GET["session_id"],
|
|
754
|
+
tenant: req_tenant(req))
|
|
626
755
|
sse_response(subscription)
|
|
627
756
|
end
|
|
628
757
|
|
|
@@ -650,8 +779,15 @@ module Insika
|
|
|
650
779
|
# transport (TaskFilter). A SYNCHRONOUS handler error (Validation/NotFound)
|
|
651
780
|
# happens here, BEFORE the SSE opens -> closes the subscription and propagates to the
|
|
652
781
|
# #call rescue (becomes an HTTP status).
|
|
653
|
-
def message_flow(payload, stream:, serialize: nil, transport: :http)
|
|
654
|
-
|
|
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)
|
|
655
791
|
subscription = @event_stream.subscribe
|
|
656
792
|
result =
|
|
657
793
|
begin
|
|
@@ -661,7 +797,7 @@ module Insika
|
|
|
661
797
|
raise
|
|
662
798
|
end
|
|
663
799
|
|
|
664
|
-
#
|
|
800
|
+
# the message joined another turn — one still waiting at the
|
|
665
801
|
# door (`merged`) or one already running (`steered`). Either way this call
|
|
666
802
|
# owns no reply; the one holding `task_id` does. Say exactly that and open no
|
|
667
803
|
# stream: a caller that delivered this response's (empty) output would
|
|
@@ -682,14 +818,20 @@ module Insika
|
|
|
682
818
|
stream ? sse_response(filtered, serialize: serialize) : aggregate_response(filtered, task_id)
|
|
683
819
|
end
|
|
684
820
|
|
|
685
|
-
# Workflow trigger flow
|
|
821
|
+
# Workflow trigger flow. Async by default (the honest workflow
|
|
686
822
|
# contract: fire the run, return the runId); ?stream=true streams the run's
|
|
687
823
|
# events like a turn. Same subscribe-before-dispatch discipline as
|
|
688
824
|
# message_flow so no eager event is lost when streaming. A synchronous handler
|
|
689
825
|
# error (bad input / unknown workflow) closes the subscription and propagates
|
|
690
826
|
# to #call (HTTP status).
|
|
691
|
-
def workflow_flow(payload, stream:)
|
|
692
|
-
|
|
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)
|
|
693
835
|
|
|
694
836
|
unless stream
|
|
695
837
|
result = dispatch_with_timeout(command)
|
|
@@ -724,7 +866,11 @@ module Insika
|
|
|
724
866
|
events << event.to_h
|
|
725
867
|
case event.type
|
|
726
868
|
when :content then content << event.data[:delta].to_s
|
|
727
|
-
when :task_failed
|
|
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))
|
|
728
874
|
when :task_cancelled then error = { class: "Insika::CancelledError", message: "task cancelled" }
|
|
729
875
|
when :error then error ||= { class: nil, message: event.data[:message] }
|
|
730
876
|
end
|
|
@@ -812,12 +958,38 @@ module Insika
|
|
|
812
958
|
json_response(status, { error: { class: error.class.name, message: error.message } })
|
|
813
959
|
end
|
|
814
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
|
+
|
|
815
987
|
def not_found
|
|
816
988
|
[404, { "content-type" => "text/plain" }, ["not found"]]
|
|
817
989
|
end
|
|
818
990
|
|
|
819
991
|
# Liveness/readiness. Fixed 200: if the process accepts the connection and recovery
|
|
820
|
-
# has already run (Boot only returns the app afterward
|
|
992
|
+
# has already run (Boot only returns the app afterward), it's ready. Does NOT
|
|
821
993
|
# touch a store (health cannot fail on IO nor require auth).
|
|
822
994
|
def health = json_response(200, { status: "ok" })
|
|
823
995
|
|
data/lib/insika/server/boot.rb
CHANGED
|
@@ -52,12 +52,12 @@ module Insika
|
|
|
52
52
|
Sync { do_recovery }
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Task recovery THEN delegation recovery
|
|
55
|
+
# Task recovery THEN delegation recovery: the delegation
|
|
56
56
|
# sweep re-delivers completed-but-undelivered async delegations, and depends
|
|
57
57
|
# on the task sweep having re-dispatched any in-flight children first. Both
|
|
58
58
|
# create task fibers, so both must run inside the reactor scope of run_recovery.
|
|
59
59
|
#
|
|
60
|
-
# The TASK sweep is additionally gated per boot generation
|
|
60
|
+
# The TASK sweep is additionally gated per boot generation:
|
|
61
61
|
# its "orphaned :running" test cannot see a sibling worker's live fiber, so
|
|
62
62
|
# only the worker that claims the generation sweeps — the others would steal
|
|
63
63
|
# in-flight turns. The delegation and channel sweeps stay ungated: each of
|
|
@@ -89,7 +89,7 @@ module Insika
|
|
|
89
89
|
log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
#
|
|
92
|
+
# replies a previous process committed but never handed to the
|
|
93
93
|
# channel. Runs AFTER the task recovery for the same reason the delegation
|
|
94
94
|
# sweep does — a resumed turn writes its own outbox record at its terminal, and
|
|
95
95
|
# sweeping first would miss it.
|
|
@@ -108,7 +108,7 @@ module Insika
|
|
|
108
108
|
return if @wiring.durable?
|
|
109
109
|
|
|
110
110
|
log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
|
|
111
|
-
"not resume anything after a restart
|
|
111
|
+
"not resume anything after a restart.")
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def log(message)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# the /v1 transport as a VALUE the host app mounts, instead of a
|
|
4
4
|
# server the engine starts.
|
|
5
5
|
#
|
|
6
6
|
# mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# server boot now calls this instead of inlining it, which is what keeps the two
|
|
12
12
|
# from drifting.
|
|
13
13
|
#
|
|
14
|
-
# The Studio is deliberately NOT part of this (the embed contract
|
|
14
|
+
# The Studio is deliberately NOT part of this (the embed contract): it is
|
|
15
15
|
# a class-level singleton, so it is one per process, and a host that wants the
|
|
16
16
|
# operator UI mounts `Studio::App` itself and accepts that limitation.
|
|
17
17
|
|
|
@@ -44,27 +44,35 @@ module Insika
|
|
|
44
44
|
attr_reader :token
|
|
45
45
|
|
|
46
46
|
def app
|
|
47
|
+
tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
|
|
48
|
+
# WS1: the token store is handed over ONLY in multi_tenant mode — in
|
|
49
|
+
# single_tenant the classic gateway token is the only credential
|
|
50
|
+
# (passing the store would silently widen the surface).
|
|
51
|
+
store = tenancy == "multi_tenant" ? @graph.token_store : nil
|
|
47
52
|
@app ||= Insika::Server::App.new(
|
|
48
53
|
command_bus: @graph.bus, event_stream: @graph.event_stream,
|
|
49
54
|
session_store: @graph.session_store, task_store: @graph.task_store,
|
|
50
55
|
pending_action_store: @graph.pending_action_store,
|
|
51
56
|
provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
|
|
52
57
|
# GET /v1/agents/:id — the read-only capability view a case's `requires`
|
|
53
|
-
# resolves against
|
|
58
|
+
# resolves against.
|
|
54
59
|
profiles: @graph.profiles,
|
|
55
|
-
#
|
|
60
|
+
# the OSS onboarding surface (start.md + models.json + docs).
|
|
56
61
|
# This is the primary "build my first agent" target — models.json reports the
|
|
57
62
|
# DSL's stores + the agents this process serves (each id IS the `model`).
|
|
58
63
|
onboarding: build_onboarding,
|
|
59
|
-
#
|
|
64
|
+
# GET /v1/workflows + POST /v1/workflows/:name, opt-in by
|
|
60
65
|
# injection like every other edge — nil when the system declares none,
|
|
61
66
|
# so the routes simply do not exist (404, parity).
|
|
62
67
|
workflow_registry: (@graph.workflow_registry if workflows?),
|
|
63
|
-
#
|
|
68
|
+
# the bundled relay, when the env turns it on. Same rule as the
|
|
64
69
|
# OTEL bridge — a feature only `config.ru` can reach is a feature the
|
|
65
70
|
# docs are half-true about.
|
|
66
71
|
channels: (@graph.channel_registry if channels?),
|
|
67
|
-
config: { gateway_token: @token }.merge(@config)
|
|
72
|
+
config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
|
|
73
|
+
token_store: store,
|
|
74
|
+
# a 500's error_ref must be findable in the process log.
|
|
75
|
+
logger: $stdout
|
|
68
76
|
)
|
|
69
77
|
end
|
|
70
78
|
|