samagotchi 0.102.0 → 0.104.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 +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +28 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -42,11 +42,19 @@ require_relative "../context_note"
|
|
|
42
42
|
require_relative "../context_sources"
|
|
43
43
|
require_relative "../context_providers"
|
|
44
44
|
require_relative "../recap_store"
|
|
45
|
+
require_relative "../toolkit"
|
|
46
|
+
require_relative "../turn_flow"
|
|
47
|
+
require_relative "../session_chain"
|
|
48
|
+
require_relative "../parent_answers_command"
|
|
49
|
+
require_relative "../guardrails/parent_policy"
|
|
45
50
|
require_relative "markdown_renderer"
|
|
46
51
|
require_relative "capabilities"
|
|
47
52
|
require_relative "editor"
|
|
48
53
|
require_relative "../message_parts"
|
|
49
54
|
require_relative "session_summary"
|
|
55
|
+
require_relative "session_access_routes"
|
|
56
|
+
require_relative "principal"
|
|
57
|
+
require_relative "participant_access"
|
|
50
58
|
require_relative "../log"
|
|
51
59
|
require_relative "../paths"
|
|
52
60
|
require_relative "../redacted_inspect"
|
|
@@ -63,6 +71,8 @@ module Samagotchi
|
|
|
63
71
|
class App
|
|
64
72
|
# @lan holds the LAN access token.
|
|
65
73
|
include RedactedInspect
|
|
74
|
+
include SessionAccessRoutes
|
|
75
|
+
include ParticipantAccess
|
|
66
76
|
|
|
67
77
|
DEFAULT_HOST = BridgeClient::HOST
|
|
68
78
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
@@ -76,7 +86,11 @@ module Samagotchi
|
|
|
76
86
|
IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
|
|
77
87
|
"webp" => "image/webp" }.freeze
|
|
78
88
|
|
|
79
|
-
# POST /stop waits this long for the worker to let go of the session
|
|
89
|
+
# POST /stop waits this long for the worker to let go of the session
|
|
90
|
+
# when the body names no wait (the page's Stop: it shows the stop at
|
|
91
|
+
# once, and a Rack thread isn't held for it). A CLI through --remote
|
|
92
|
+
# sends its own, SessionManager::STOP_WAIT at most, so chi sessions
|
|
93
|
+
# stop waits as long as here.
|
|
80
94
|
STOP_WAIT_SECONDS = 2.0
|
|
81
95
|
|
|
82
96
|
# GET /api/events: a `: ping` this often while idle, a queue this deep
|
|
@@ -113,17 +127,22 @@ module Samagotchi
|
|
|
113
127
|
# (nil: built from config.yml's hosts:, again when they change)
|
|
114
128
|
# @param models_wait_timeout [Float] bounded seconds GET /api/models
|
|
115
129
|
# waits for the hosts' lists
|
|
130
|
+
# @param wait_poll_interval [Float] how often POST
|
|
131
|
+
# /api/sessions/:id/wait looks at the session (ReplyWait's poll)
|
|
116
132
|
# @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
|
|
117
133
|
# the address the server also listens on and its access token (a
|
|
118
134
|
# String, or a Web::Token::Source that follows the file); nil:
|
|
119
135
|
# loopback only, no token
|
|
136
|
+
# @param grants [Grants, nil] the participants' grants (default: the
|
|
137
|
+
# store next to +state_dir+, Grants.path)
|
|
120
138
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
121
139
|
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
122
140
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
123
141
|
editor: Config::BY_KEY["web.editor"].default,
|
|
124
142
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
125
143
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
|
|
126
|
-
working_set: Config::BY_KEY["web.working_set"].default
|
|
144
|
+
working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
|
|
145
|
+
grants: nil)
|
|
127
146
|
@manager = manager || SessionManager
|
|
128
147
|
@registry = registry
|
|
129
148
|
@registry_given = !registry.nil?
|
|
@@ -146,6 +165,8 @@ module Samagotchi
|
|
|
146
165
|
@lan = lan
|
|
147
166
|
@refused_mutex = Mutex.new
|
|
148
167
|
@refused_logged = {}
|
|
168
|
+
@wait_poll_interval = wait_poll_interval
|
|
169
|
+
init_participant_access(grants, state_dir)
|
|
149
170
|
end
|
|
150
171
|
|
|
151
172
|
def call(env)
|
|
@@ -158,39 +179,63 @@ module Samagotchi
|
|
|
158
179
|
private
|
|
159
180
|
|
|
160
181
|
# The routes, first match wins: a handler takes the request and the
|
|
161
|
-
# path's captures (the session id, an image's name).
|
|
162
|
-
#
|
|
182
|
+
# path's captures (the session id, an image's name). The fourth column
|
|
183
|
+
# is who may use the route (ROUTE_PERMISSIONS, checked against the
|
|
184
|
+
# request's Principal): :owner, or :participant for one a participant
|
|
185
|
+
# may use too, its handler answering them as theirs (the participant
|
|
186
|
+
# page, the trimmed info, the filtered show; docs/internals/
|
|
187
|
+
# participants.md). A :session route runs in #session_route: an
|
|
188
|
+
# ArgumentError is its unknown session.
|
|
189
|
+
ROUTE_PERMISSIONS = %i[owner participant].freeze
|
|
163
190
|
ROUTES = [
|
|
164
|
-
["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
|
|
165
|
-
["GET", %r{\A/api/sessions\z}, :handle_list],
|
|
166
|
-
["POST", %r{\A/api/sessions\z}, :handle_create],
|
|
167
|
-
["GET", %r{\A/api/
|
|
168
|
-
["GET", %r{\A/api/
|
|
169
|
-
["GET", %r{\A/api/
|
|
170
|
-
["GET", %r{\A/api/
|
|
171
|
-
["
|
|
172
|
-
["GET", %r{\A/api/
|
|
173
|
-
["
|
|
174
|
-
["
|
|
175
|
-
["
|
|
176
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
177
|
-
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :
|
|
178
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
179
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
180
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
181
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
182
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
183
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
184
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
185
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
186
|
-
["
|
|
187
|
-
["
|
|
188
|
-
["GET", %r{\A/api/sessions/([^/]+)/
|
|
189
|
-
["POST", %r{\A/api/sessions/([^/]+)/
|
|
190
|
-
["
|
|
191
|
-
["GET", %r{\A/api/sessions/([^/]+)/
|
|
192
|
-
["GET", %r{\A/api/sessions/([^/]+)\z}, :
|
|
193
|
-
["
|
|
191
|
+
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
192
|
+
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
193
|
+
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
194
|
+
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :owner],
|
|
195
|
+
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
196
|
+
["GET", %r{\A/api/info\z}, :handle_info, :participant],
|
|
197
|
+
["GET", %r{\A/api/models\z}, :handle_models, :owner],
|
|
198
|
+
["GET", %r{\A/api/history\z}, :handle_history, :owner],
|
|
199
|
+
["GET", %r{\A/api/events\z}, :handle_events, :owner],
|
|
200
|
+
["POST", %r{\A/api/render\z}, :handle_render, :owner],
|
|
201
|
+
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :owner],
|
|
202
|
+
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :owner, :session],
|
|
203
|
+
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :owner, :session],
|
|
204
|
+
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
|
|
205
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :owner, :session],
|
|
206
|
+
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
|
|
207
|
+
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
|
|
208
|
+
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
|
|
209
|
+
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
|
|
210
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :owner, :session],
|
|
211
|
+
["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :owner, :session],
|
|
212
|
+
["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :owner, :session],
|
|
213
|
+
["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :owner, :session],
|
|
214
|
+
["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :owner, :session],
|
|
215
|
+
["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
|
|
216
|
+
["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
|
|
217
|
+
["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
|
|
218
|
+
["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
|
|
219
|
+
["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
|
|
220
|
+
["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
|
|
221
|
+
["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer, :owner],
|
|
222
|
+
["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss, :owner],
|
|
223
|
+
["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :owner, :session],
|
|
224
|
+
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload, :owner],
|
|
225
|
+
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :owner],
|
|
226
|
+
["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :owner, :session],
|
|
227
|
+
["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :owner, :session],
|
|
228
|
+
["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :owner, :session],
|
|
229
|
+
["POST", %r{\A/api/sessions/([^/]+)/context/([^/]+)/push\z}, :handle_context_push, :owner, :session],
|
|
230
|
+
["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :owner, :session],
|
|
231
|
+
["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :owner, :session],
|
|
232
|
+
["GET", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_list, :owner, :session],
|
|
233
|
+
["POST", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_invite, :owner, :session],
|
|
234
|
+
["DELETE", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_revoke, :owner, :session],
|
|
235
|
+
["GET", %r{\A/api/participants\z}, :handle_participants_list, :owner],
|
|
236
|
+
["DELETE", %r{\A/api/participants\z}, :handle_participants_revoke, :owner],
|
|
237
|
+
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
238
|
+
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
|
|
194
239
|
].freeze
|
|
195
240
|
|
|
196
241
|
def route(env)
|
|
@@ -198,16 +243,22 @@ module Samagotchi
|
|
|
198
243
|
return forbidden unless local_host_header?(env)
|
|
199
244
|
return cross_origin if cross_site?(env)
|
|
200
245
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return denied if denied
|
|
204
|
-
end
|
|
246
|
+
principal, denied = token_gate(req)
|
|
247
|
+
return denied if denied
|
|
205
248
|
|
|
206
|
-
|
|
249
|
+
# The principal the handlers read (#principal): never the body's.
|
|
250
|
+
env[PRINCIPAL_ENV] = principal
|
|
251
|
+
participant_seen(req, principal) unless principal.owner?
|
|
252
|
+
ROUTES.each do |verb, pattern, handler, permission, kind|
|
|
207
253
|
next unless req.request_method == verb && (m = pattern.match(req.path_info))
|
|
254
|
+
# Before the permission: a participant learns nothing of another session.
|
|
255
|
+
return unknown_session(m.captures.first) if other_session?(principal, req.path_info, m.captures)
|
|
256
|
+
return not_permitted_for(req, principal) unless principal.permits?(permission)
|
|
257
|
+
|
|
208
258
|
# Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
|
|
209
259
|
# "..", a %2F) is an unknown session before any handler makes a path.
|
|
210
|
-
|
|
260
|
+
# (/api/sessions/resolve and /summaries take no id.)
|
|
261
|
+
if req.path_info.start_with?("/api/sessions/") && !m.captures.empty? && !Session.valid_id?(m.captures.first)
|
|
211
262
|
return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
|
|
212
263
|
end
|
|
213
264
|
|
|
@@ -293,25 +344,57 @@ module Samagotchi
|
|
|
293
344
|
TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
|
|
294
345
|
REFUSED_LOG_INTERVAL = 60.0
|
|
295
346
|
|
|
347
|
+
# Where #route keeps the request's Principal for its handlers.
|
|
348
|
+
PRINCIPAL_ENV = "samagotchi.principal"
|
|
349
|
+
PAGE_PATHS = ["/", "/index.html"].freeze
|
|
350
|
+
|
|
351
|
+
# The request's Principal, as #route resolved it.
|
|
352
|
+
def principal(req) = req.env.fetch(PRINCIPAL_ENV)
|
|
353
|
+
|
|
296
354
|
# LAN mode, a peer that isn't this machine (the peer is the socket's
|
|
297
355
|
# address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
|
|
298
356
|
# headers a phone could send): every route needs the access token, as
|
|
299
357
|
# the chi_token cookie or an Authorization: Bearer header. The page
|
|
300
358
|
# with ?token= trades it for the cookie and a 303 to itself without
|
|
301
359
|
# it, so the token doesn't stay in the phone's history.
|
|
302
|
-
# @return [Array, nil
|
|
360
|
+
# @return [Array(Principal, nil), Array(nil, Array)] the request's
|
|
361
|
+
# principal (#principal_for), or the response that refuses it
|
|
303
362
|
def token_gate(req)
|
|
363
|
+
principal = principal_for(req)
|
|
364
|
+
return [nil, grant_ended_response(req) || unauthorized(req)] if principal.nil?
|
|
365
|
+
# The cookie gets the token that was presented: the owner's, or the
|
|
366
|
+
# participant's grant's.
|
|
367
|
+
return [nil, token_redirect(req, req.env[GRANT_TOKEN_ENV] || current_token)] if token_trade?(req)
|
|
368
|
+
|
|
369
|
+
[principal, nil]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Who the request is: the owner for a peer the gate doesn't ask (no
|
|
373
|
+
# LAN mode, or this machine's loopback) and for the access token (the
|
|
374
|
+
# page's ?token= on a trade, else the Bearer header or the cookie);
|
|
375
|
+
# a participant for a live grant's token (Grants, looked up only when
|
|
376
|
+
# no owner token was given); nil for anyone else.
|
|
377
|
+
# @return [Principal, nil]
|
|
378
|
+
def principal_for(req)
|
|
379
|
+
return Principal::OWNER unless token_required?(req)
|
|
380
|
+
|
|
304
381
|
token = current_token
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
else
|
|
308
|
-
auth = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
|
|
309
|
-
return nil if token_match?(auth, token) || token_match?(req.cookies[TOKEN_COOKIE], token)
|
|
310
|
-
end
|
|
382
|
+
given = token_trade?(req) ? [req.params["token"]] : [bearer_token(req), req.cookies[TOKEN_COOKIE]]
|
|
383
|
+
return Principal::OWNER if given.any? { |value| token_match?(value, token) }
|
|
311
384
|
|
|
312
|
-
|
|
385
|
+
participant_principal(req, given.select { |value| Grants.token?(value) })
|
|
313
386
|
end
|
|
314
387
|
|
|
388
|
+
def token_required?(req) = @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
|
|
389
|
+
|
|
390
|
+
# The page opened with ?token= (a gated request): its token is traded
|
|
391
|
+
# for the cookie, whatever else the request carries.
|
|
392
|
+
def token_trade?(req)
|
|
393
|
+
token_required?(req) && req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def bearer_token(req) = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
|
|
397
|
+
|
|
315
398
|
def current_token
|
|
316
399
|
token = @lan[:token]
|
|
317
400
|
token.respond_to?(:current) ? token.current : token
|
|
@@ -398,6 +481,11 @@ module Samagotchi
|
|
|
398
481
|
error_response(403, "cross_origin", "requests from other websites are refused")
|
|
399
482
|
end
|
|
400
483
|
|
|
484
|
+
# A route the request's principal may not use (Principal#permits?).
|
|
485
|
+
def not_permitted
|
|
486
|
+
error_response(403, "not_permitted", "only the session's owner can do that")
|
|
487
|
+
end
|
|
488
|
+
|
|
401
489
|
def forbidden
|
|
402
490
|
names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
|
|
403
491
|
error_response(403, "forbidden", "only the host names #{names} are answered")
|
|
@@ -450,12 +538,21 @@ module Samagotchi
|
|
|
450
538
|
# What a second `chi web` probes before starting its own server: this
|
|
451
539
|
# is chi web, and it knows ?dir (features). installed: the newest chi
|
|
452
540
|
# on this machine (nil until the hub has looked); newest_workers: the
|
|
453
|
-
# workers it starts run that one, not this chi web's version
|
|
454
|
-
|
|
541
|
+
# workers it starts run that one, not this chi web's version;
|
|
542
|
+
# session_commands: the routes chi --remote needs
|
|
543
|
+
# (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
|
|
544
|
+
# attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
|
|
545
|
+
# listens on loopback only. A participant gets app, version and
|
|
546
|
+
# features only (#participant_info).
|
|
547
|
+
def handle_info(req)
|
|
548
|
+
return participant_info unless principal(req).owner?
|
|
549
|
+
|
|
455
550
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
|
|
456
|
-
cwd: Dir.pwd, features:
|
|
551
|
+
cwd: Dir.pwd, features: INFO_FEATURES, lan: @lan && @lan[:ip] })
|
|
457
552
|
end
|
|
458
553
|
|
|
554
|
+
INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE].freeze
|
|
555
|
+
|
|
459
556
|
# The models a new session can start on, spelled as chi spells them
|
|
460
557
|
# elsewhere: bare for the default host (what default.model holds), and
|
|
461
558
|
# host:model for another host. The default is the configured model, or
|
|
@@ -679,17 +776,32 @@ module Samagotchi
|
|
|
679
776
|
return error_response(400, "invalid_settings", e.message)
|
|
680
777
|
end
|
|
681
778
|
|
|
779
|
+
# toolkit, tools, no_tools: its toolkit (chi send --new --toolkit,
|
|
780
|
+
# --tool, --no-tool: a name, tool names added and left out);
|
|
781
|
+
# parent_answers: what a parent agent may answer on its questions
|
|
782
|
+
# (hard, standard or soft; a looser one than config.yml's is capped,
|
|
783
|
+
# and the 201's parent_answers_warning says so).
|
|
784
|
+
toolkit, error = request_toolkit(body)
|
|
785
|
+
return error if error
|
|
786
|
+
|
|
787
|
+
parent_answers, error = request_parent_answers(body["parent_answers"])
|
|
788
|
+
return error if error
|
|
789
|
+
|
|
790
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
791
|
+
refused = create_refusal(body, idle ? body["preview"] : prompt, values: [parent_answers], model: model)
|
|
792
|
+
return refused if refused
|
|
793
|
+
|
|
682
794
|
folder = dir ? { working_directory: dir } : {}
|
|
683
795
|
# model: the model the session starts on (the start page's picker,
|
|
684
796
|
# spelled as GET /api/models lists it); blank means the default.
|
|
685
|
-
model = (body["model"] || body["model_name"]).to_s.strip
|
|
686
797
|
folder[:model_name] = model unless model.empty?
|
|
687
798
|
# preview: an idle session's first message, sent as its first turn
|
|
688
799
|
# next; the list and the notifications name the session by it
|
|
689
800
|
# until that turn is saved.
|
|
690
801
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
691
802
|
folder[:preview] = preview unless preview.empty?
|
|
692
|
-
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings
|
|
803
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings, toolkit: toolkit,
|
|
804
|
+
parent_answers: parent_answers)
|
|
693
805
|
folder[:setup] = setup unless setup.empty?
|
|
694
806
|
begin
|
|
695
807
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
@@ -703,7 +815,8 @@ module Samagotchi
|
|
|
703
815
|
# The projection holds it before the page hears back (no tick wait).
|
|
704
816
|
@hub&.touch(session.id)
|
|
705
817
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
706
|
-
json_response(201, session_to_json(session).merge(bridge_port: port
|
|
818
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning,
|
|
819
|
+
parent_answers_warning: Guardrails::ParentPolicy.capped_note(parent_answers)))
|
|
707
820
|
end
|
|
708
821
|
|
|
709
822
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
@@ -724,14 +837,17 @@ module Samagotchi
|
|
|
724
837
|
unless given.is_a?(String) && !given.strip.empty?
|
|
725
838
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
726
839
|
end
|
|
727
|
-
if (fields =
|
|
840
|
+
if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
|
|
728
841
|
return error_response(400, "invalid_continues",
|
|
729
|
-
"continues takes the previous session's folder, llm_context, thinking
|
|
730
|
-
"leave out #{fields.join(", ")}")
|
|
842
|
+
"continues takes the previous session's folder, llm_context, thinking, toolkit, " \
|
|
843
|
+
"settings and parent_answers; leave out #{fields.join(", ")}")
|
|
731
844
|
end
|
|
732
845
|
|
|
733
846
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
734
847
|
model = (body["model"] || body["model_name"]).to_s.strip
|
|
848
|
+
refused = continue_refusal(given, body, idle ? body["preview"] : prompt, model: model)
|
|
849
|
+
return refused if refused
|
|
850
|
+
|
|
735
851
|
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
|
|
736
852
|
model: model.empty? ? nil : model)
|
|
737
853
|
return error if error
|
|
@@ -746,22 +862,123 @@ module Samagotchi
|
|
|
746
862
|
opener: session.opener))
|
|
747
863
|
end
|
|
748
864
|
|
|
865
|
+
# chi send's client ids: its lines are checked as chi send checks them
|
|
866
|
+
# (#parent_gate).
|
|
867
|
+
CLI_SEND_IDS = [ClientId::CLI_SEND, ClientId::CLI_ANSWER].freeze
|
|
868
|
+
|
|
869
|
+
# What a continue takes from the previous link: refused beside it.
|
|
870
|
+
CONTINUE_TAKES = %w[dir llm_context thinking settings toolkit tools no_tools parent_answers].freeze
|
|
871
|
+
|
|
872
|
+
# The previous link's parent_answers and the model the new one runs,
|
|
873
|
+
# for #create_refusal; nil when the previous link isn't there
|
|
874
|
+
# (continue_from says so).
|
|
875
|
+
def continue_refusal(given, body, line, model:)
|
|
876
|
+
return nil unless body["check_vision"] == true || CLI_SEND_IDS.include?(body["client_id"])
|
|
877
|
+
|
|
878
|
+
previous = Session.load(SessionChain.resolve(given, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
879
|
+
create_refusal(body, line, values: [previous.parent_answers], model: model, previous: previous)
|
|
880
|
+
rescue ArgumentError, ModelProfile::MissingModel
|
|
881
|
+
nil
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# A create refused before anything spawns, as chi send --new refuses
|
|
885
|
+
# it (SendCommand::ParentGate, the vision check): its first line
|
|
886
|
+
# (+line+, the prompt or an idle one's preview) when it is a parent
|
|
887
|
+
# agent's /continue yes or a /parent-answers that loosens +values+
|
|
888
|
+
# (#parent_gate), and with check_vision: true (its first message
|
|
889
|
+
# brings images) a model known not to take them (422 no_vision).
|
|
890
|
+
# @param previous [Session, nil] a continue's previous link: its model
|
|
891
|
+
# decides when +model+ is blank
|
|
892
|
+
# @return [Array, nil] the response, or nil to go on
|
|
893
|
+
def create_refusal(body, line, values:, model:, previous: nil)
|
|
894
|
+
refused = parent_gate(line, client_id: body["client_id"], values: values)
|
|
895
|
+
return refused if refused
|
|
896
|
+
return nil unless body["check_vision"] == true
|
|
897
|
+
|
|
898
|
+
name, typed = if previous
|
|
899
|
+
SendCommand.continued_model(previous, model)
|
|
900
|
+
else
|
|
901
|
+
[model.empty? ? nil : model, nil]
|
|
902
|
+
end
|
|
903
|
+
vision_refused(name, typed)
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# A line from chi send (client_id cli:send, or cli:answer for a parent
|
|
907
|
+
# agent's /continue yes) refused here as chi send refuses it on its own
|
|
908
|
+
# machine (SendCommand::ParentGate), held to this server's config.yml:
|
|
909
|
+
# a parent's /continue yes that guardrails.parent_answers (tightened by
|
|
910
|
+
# +values+, the targets' own parent_answers) refuses, 403
|
|
911
|
+
# parent_continue_refused; a /parent-answers line that loosens one of
|
|
912
|
+
# +values+, 403 parent_answers_refused (loosening is the user's at a
|
|
913
|
+
# UI). The page's lines (web:…) and others aren't checked here; the
|
|
914
|
+
# worker checks every line again.
|
|
915
|
+
# @return [Array, nil] the response, or nil to go on
|
|
916
|
+
def parent_gate(line, client_id:, values:)
|
|
917
|
+
line = line.to_s
|
|
918
|
+
return nil unless CLI_SEND_IDS.include?(client_id)
|
|
919
|
+
|
|
920
|
+
if client_id == ClientId::CLI_ANSWER && TurnFlow.continue_yes?(line) &&
|
|
921
|
+
(refusal = SendCommand::ParentGate.continue_refusal_for(values))
|
|
922
|
+
return error_response(403, "parent_continue_refused", refusal)
|
|
923
|
+
end
|
|
924
|
+
return nil unless SendCommand::ParentGate.loosens_any?(line, values)
|
|
925
|
+
|
|
926
|
+
error_response(403, "parent_answers_refused", ParentAnswersCommand::REFUSED)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# 422 no_vision in chi send's words when the model (+name+, nil: the
|
|
930
|
+
# default) is known not to take images; nil when it does or is
|
|
931
|
+
# unknown (the worker decides when the turn runs).
|
|
932
|
+
def vision_refused(name, typed)
|
|
933
|
+
refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
|
|
934
|
+
refusal && error_response(422, "no_vision", refusal)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# The create request's toolkit (a name), tools and no_tools (names, a
|
|
938
|
+
# comma list each too), as chi send --new's --toolkit, --tool and
|
|
939
|
+
# --no-tool: [toolkit, nil] (nil for none), or [nil, a 400 response].
|
|
940
|
+
def request_toolkit(body)
|
|
941
|
+
name = body["toolkit"]
|
|
942
|
+
lists = %w[tools no_tools].to_h { |key| [key, body[key]] }
|
|
943
|
+
unless (name.nil? || name.is_a?(String)) &&
|
|
944
|
+
lists.values.all? { |list| list.nil? || (list.is_a?(Array) && list.all?(String)) }
|
|
945
|
+
return [nil, error_response(400, "invalid_toolkit", "toolkit is a name; tools and no_tools are lists of names")]
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
[Toolkit.from_flags(name: name, add: lists["tools"] || [], remove: lists["no_tools"] || []), nil]
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# The create request's parent_answers (hard, standard or soft;
|
|
952
|
+
# "default" or none: config.yml's): [level, nil] or [nil, a 400].
|
|
953
|
+
def request_parent_answers(raw)
|
|
954
|
+
return [nil, nil] if raw.nil?
|
|
955
|
+
|
|
956
|
+
level = raw.is_a?(String) ? Guardrails::ParentPolicy.parse_level(raw) : :invalid
|
|
957
|
+
return [nil, nil] if level == :unset
|
|
958
|
+
unless Guardrails::ParentPolicy::ORDER.include?(level)
|
|
959
|
+
return [nil, error_response(400, "invalid_parent_answers",
|
|
960
|
+
"parent_answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}")]
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
[level, nil]
|
|
964
|
+
end
|
|
965
|
+
|
|
749
966
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
750
967
|
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
751
968
|
# the new link started is not a refusal of it.
|
|
752
969
|
def continue_from(given, prompt:, preview:, model:)
|
|
753
970
|
[@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
|
|
754
971
|
rescue SessionContinue::Refused => e
|
|
755
|
-
extra = e.reason == :continued ? { next_id: e.ids.first } : {
|
|
756
|
-
[nil,
|
|
972
|
+
extra = e.reason == :continued ? { next_id: e.ids.first } : {}
|
|
973
|
+
[nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
|
|
757
974
|
rescue SessionManager::ArchiveRefused => e
|
|
758
|
-
[nil,
|
|
759
|
-
rescue SessionManager::OwnedByTUI
|
|
760
|
-
[nil, owned_by_repl(given)]
|
|
975
|
+
[nil, archive_refused(e)]
|
|
976
|
+
rescue SessionManager::OwnedByTUI => e
|
|
977
|
+
[nil, owned_by_repl(given, e)]
|
|
761
978
|
rescue ModelProfile::MissingModel => e
|
|
762
979
|
[nil, error_response(400, "invalid_model", e.message)]
|
|
763
980
|
rescue Session::AmbiguousId => e
|
|
764
|
-
[nil,
|
|
981
|
+
[nil, refusal_response(400, "ambiguous_id", e, ids: e.ids)]
|
|
765
982
|
rescue ArgumentError => e
|
|
766
983
|
[nil, error_response(404, "not_found", e.message)]
|
|
767
984
|
end
|
|
@@ -800,10 +1017,13 @@ module Samagotchi
|
|
|
800
1017
|
end
|
|
801
1018
|
|
|
802
1019
|
def handle_show(req, id)
|
|
1020
|
+
return participant_show(id) unless principal(req).owner?
|
|
1021
|
+
|
|
803
1022
|
# The page's light re-reads: never the whole conversation.
|
|
804
1023
|
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
805
1024
|
return handle_tail_read(req, id) if req.params["tail"] == "1"
|
|
806
1025
|
return handle_timing_read(id) if req.params["timing"] == "1"
|
|
1026
|
+
return handle_brief_read(id) if req.params["brief"] == "1"
|
|
807
1027
|
|
|
808
1028
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
809
1029
|
# Read-only preview: selecting a session never spawns a worker.
|
|
@@ -1111,17 +1331,38 @@ module Samagotchi
|
|
|
1111
1331
|
qid = body["id"]
|
|
1112
1332
|
return error_response(400, "missing_fields", "id is required") if qid.to_s.strip.empty?
|
|
1113
1333
|
|
|
1114
|
-
|
|
1334
|
+
# client_id: only chi answer's (a parent agent's answer, which the
|
|
1335
|
+
# worker holds to its guardrails.parent_answers) is passed on; any
|
|
1336
|
+
# other is dropped, so a forward can only tighten (an answer never
|
|
1337
|
+
# passes for the user's own by it).
|
|
1338
|
+
client_id = body["client_id"] == ClientId::CLI_ANSWER ? ClientId::CLI_ANSWER : nil
|
|
1339
|
+
request = once_more_on_late do |client|
|
|
1340
|
+
client.answer(id: qid, selected: body["selected"], freeform: body["freeform"], client_id: client_id)
|
|
1341
|
+
end
|
|
1115
1342
|
relay(id, bridge_client(id), request, what: "the answer was not sent", cant: "answer questions") do |reply|
|
|
1116
1343
|
case reply.status
|
|
1117
1344
|
when 200 then json_response(200, { status: "answered", session_id: id, id: qid })
|
|
1118
1345
|
# Another client answered first (or the question was cancelled).
|
|
1119
1346
|
when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
|
|
1120
1347
|
when 400 then error_response(400, "invalid_answer", reply_detail(reply, "answer rejected"))
|
|
1348
|
+
# The worker's guardrails.parent_answers refused a parent's option.
|
|
1349
|
+
when 403 then json_response(403, { error: "parent_answer_refused", reason: reply.json&.dig("reason"),
|
|
1350
|
+
detail: reply_detail(reply, "the worker refused that option") })
|
|
1121
1351
|
end
|
|
1122
1352
|
end
|
|
1123
1353
|
end
|
|
1124
1354
|
|
|
1355
|
+
# A request to the worker sent once more when the worker dropped it as
|
|
1356
|
+
# past its deadline (408), as chi answer does on this machine
|
|
1357
|
+
# (SessionAccess::Local#answer); a second 408 is #relay's 504
|
|
1358
|
+
# worker_timeout. The question stays open either way.
|
|
1359
|
+
def once_more_on_late
|
|
1360
|
+
lambda do |client|
|
|
1361
|
+
reply = yield client
|
|
1362
|
+
reply.status == 408 ? yield(client) : reply
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1125
1366
|
# Leave the pending question unanswered (the card's Dismiss). A question
|
|
1126
1367
|
# only exists while its worker waits on it, so this needs a live bridge.
|
|
1127
1368
|
def handle_question_dismiss(req, id)
|
|
@@ -1131,11 +1372,14 @@ module Samagotchi
|
|
|
1131
1372
|
qid = body["id"].to_s
|
|
1132
1373
|
return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
|
|
1133
1374
|
|
|
1134
|
-
request =
|
|
1375
|
+
request = once_more_on_late { |client| client.dismiss_question(id: qid) }
|
|
1135
1376
|
relay(id, bridge_client(id), request, what: "the question was not dismissed", cant: "dismiss questions") do |reply|
|
|
1136
1377
|
case reply.status
|
|
1137
1378
|
when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
|
|
1138
|
-
when 409
|
|
1379
|
+
when 409
|
|
1380
|
+
# The step-limit question can't be left unanswered.
|
|
1381
|
+
code = reply.json&.dig("error") == "not_dismissable" ? "not_dismissable" : "question_not_pending"
|
|
1382
|
+
error_response(409, code, reply_detail(reply, "question not pending"))
|
|
1139
1383
|
end
|
|
1140
1384
|
end
|
|
1141
1385
|
end
|
|
@@ -1159,15 +1403,23 @@ module Samagotchi
|
|
|
1159
1403
|
record_history(body, line) if PromptHistory.shell_line?(line)
|
|
1160
1404
|
json_response(202, reply.json || { status: "accepted" })
|
|
1161
1405
|
when 400
|
|
1162
|
-
# One of this chi's own commands the worker doesn't know: it runs an
|
|
1406
|
+
# One of this chi's own commands the worker doesn't know: it runs an
|
|
1407
|
+
# older chi. command: which one, so a remote TUI names it as its
|
|
1408
|
+
# Bridge's own 400 unknown_command would (WebBridgeClient).
|
|
1163
1409
|
name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
|
|
1164
|
-
next
|
|
1410
|
+
next older_worker_command(id, name) if name
|
|
1165
1411
|
|
|
1166
1412
|
error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
|
|
1167
1413
|
end
|
|
1168
1414
|
end
|
|
1169
1415
|
rescue SessionManager::OwnedByTUI => e
|
|
1170
|
-
|
|
1416
|
+
owned_by_tui(e)
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
# 501 not_supported for a builtin +name+ ("/title") the worker lacks.
|
|
1420
|
+
def older_worker_command(id, name)
|
|
1421
|
+
words = BridgeClient.stale_worker_message(id, cant: "run #{name}")
|
|
1422
|
+
json_response(501, { error: "not_supported", detail: words, message: words, command: name })
|
|
1171
1423
|
end
|
|
1172
1424
|
|
|
1173
1425
|
# Hand a request to the session's live Bridge (+client+, nil without
|
|
@@ -1205,8 +1457,22 @@ module Samagotchi
|
|
|
1205
1457
|
|
|
1206
1458
|
# 409 owned_by_tui in the words a refused archive, delete or continue
|
|
1207
1459
|
# shows (stop, restart, a turn and a command show OwnedByTUI's own).
|
|
1208
|
-
def owned_by_repl(id)
|
|
1209
|
-
|
|
1460
|
+
def owned_by_repl(id, error = nil)
|
|
1461
|
+
named = SessionManager::OwnedByTUI.new(id)
|
|
1462
|
+
error ||= named
|
|
1463
|
+
refusal_response(409, "owned_by_tui", error, detail: named.message, session_id: error.session_id)
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
# 409 owned_by_tui in OwnedByTUI's own words (stop, restart, a turn, a
|
|
1467
|
+
# command).
|
|
1468
|
+
def owned_by_tui(error) = refusal_response(409, "owned_by_tui", error, session_id: error.session_id)
|
|
1469
|
+
|
|
1470
|
+
# 409 scratch or busy (every other reason: a turn, a queued prompt, an
|
|
1471
|
+
# open step-limit question, in it or a delegate, +busy_id+), with the
|
|
1472
|
+
# reason for a remote client.
|
|
1473
|
+
def archive_refused(error)
|
|
1474
|
+
refusal_response(409, error.reason == :scratch ? "scratch" : "busy", error, session_id: error.session_id,
|
|
1475
|
+
reason: error.reason.to_s, busy_id: error.busy_id)
|
|
1210
1476
|
end
|
|
1211
1477
|
|
|
1212
1478
|
def not_live(id)
|
|
@@ -1266,19 +1532,44 @@ module Samagotchi
|
|
|
1266
1532
|
return error_response(400, refused[:error], refused[:detail])
|
|
1267
1533
|
end
|
|
1268
1534
|
|
|
1535
|
+
refused = turn_refusal(id, body, prompt.to_s, images)
|
|
1536
|
+
return refused if refused
|
|
1537
|
+
|
|
1538
|
+
# no_interrupt: an attached TUI's --no-interrupt (chi --remote --attach).
|
|
1539
|
+
# author: the principal's (nil for the owner), never the body's.
|
|
1269
1540
|
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
|
|
1270
|
-
delivery: delivery,
|
|
1541
|
+
delivery: delivery, no_interrupt: body["no_interrupt"] == true,
|
|
1542
|
+
author: principal(req).author,
|
|
1543
|
+
state_dir: @state_dir, manager: @manager,
|
|
1271
1544
|
bridge: -> { live_bridge_client(id) })
|
|
1272
1545
|
case result.status
|
|
1273
1546
|
when :accepted
|
|
1274
1547
|
record_history(body, prompt)
|
|
1275
|
-
|
|
1548
|
+
# via: bridge or file (the worker was gone: its input file), which
|
|
1549
|
+
# names the turn a remote chi send --wait follows (WaitTarget).
|
|
1550
|
+
json_response(202, result.ack.merge("via" => result.via.to_s))
|
|
1276
1551
|
when :refused then json_response(result.code, result.ack)
|
|
1277
1552
|
when :timeout then error_response(504, "worker_timeout", result.detail)
|
|
1278
1553
|
else error_response(500, "enqueue_failed", "could not write turn input")
|
|
1279
1554
|
end
|
|
1280
1555
|
rescue SessionManager::OwnedByTUI => e
|
|
1281
|
-
|
|
1556
|
+
owned_by_tui(e)
|
|
1557
|
+
end
|
|
1558
|
+
|
|
1559
|
+
# A turn refused before it goes in, as chi send refuses it on its own
|
|
1560
|
+
# machine: a chi send line #parent_gate refuses (the session's own
|
|
1561
|
+
# parent_answers), and with check_vision: true images to a model known
|
|
1562
|
+
# not to take them (422 no_vision). The page sends neither.
|
|
1563
|
+
# @return [Array, nil] the response, or nil to go on
|
|
1564
|
+
def turn_refusal(id, body, prompt, images)
|
|
1565
|
+
vision = body["check_vision"] == true && !images.empty?
|
|
1566
|
+
return nil unless vision || CLI_SEND_IDS.include?(body["client_id"])
|
|
1567
|
+
|
|
1568
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1569
|
+
refused = parent_gate(prompt, client_id: body["client_id"], values: [session.parent_answers])
|
|
1570
|
+
return refused if refused
|
|
1571
|
+
|
|
1572
|
+
vision ? vision_refused(session.model_name, session.model_typed) : nil
|
|
1282
1573
|
end
|
|
1283
1574
|
|
|
1284
1575
|
# A turn's images as [{file:, name:}] (refs to uploads already in the
|
|
@@ -1420,14 +1711,27 @@ module Samagotchi
|
|
|
1420
1711
|
end
|
|
1421
1712
|
end
|
|
1422
1713
|
|
|
1423
|
-
|
|
1714
|
+
# POST /api/sessions/:id/stop {wait?}: 200 {status: "stopped",
|
|
1715
|
+
# session_id, released}: released, whether the worker let go of the
|
|
1716
|
+
# session within the wait (STOP_WAIT_SECONDS, or the body's seconds up
|
|
1717
|
+
# to SessionManager::STOP_WAIT: chi sessions stop's two wordings); 409
|
|
1718
|
+
# owned_by_tui.
|
|
1719
|
+
def handle_stop(req, id)
|
|
1424
1720
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1425
1721
|
# after it just means an immediate resume finds it (rare).
|
|
1426
|
-
@manager.stop_session(id, state_dir: @state_dir, wait:
|
|
1722
|
+
released = @manager.stop_session(id, state_dir: @state_dir, wait: stop_wait(req))
|
|
1427
1723
|
@hub&.touch(id)
|
|
1428
|
-
json_response(200, { status: "stopped", session_id: id })
|
|
1724
|
+
json_response(200, { status: "stopped", session_id: id, released: released == true })
|
|
1429
1725
|
rescue SessionManager::OwnedByTUI => e
|
|
1430
|
-
|
|
1726
|
+
owned_by_tui(e)
|
|
1727
|
+
end
|
|
1728
|
+
|
|
1729
|
+
# The body's wait (a number of seconds), capped at
|
|
1730
|
+
# SessionManager::STOP_WAIT; STOP_WAIT_SECONDS without one.
|
|
1731
|
+
def stop_wait(req)
|
|
1732
|
+
body = parse_json(request_body(req, 4096))
|
|
1733
|
+
wait = body["wait"] if body.is_a?(Hash)
|
|
1734
|
+
wait.is_a?(Numeric) && wait.positive? ? [wait, SessionManager::STOP_WAIT].min : STOP_WAIT_SECONDS
|
|
1431
1735
|
end
|
|
1432
1736
|
|
|
1433
1737
|
# POST /api/sessions/:id/restart: hand the session to a new worker on
|
|
@@ -1443,9 +1747,9 @@ module Samagotchi
|
|
|
1443
1747
|
version: result.version })
|
|
1444
1748
|
rescue SessionManager::RestartRefused => e
|
|
1445
1749
|
error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
|
|
1446
|
-
|
|
1750
|
+
refusal_response(409, error, e, reason: e.reason.to_s, session_id: id, version: e.version)
|
|
1447
1751
|
rescue SessionManager::OwnedByTUI => e
|
|
1448
|
-
|
|
1752
|
+
owned_by_tui(e)
|
|
1449
1753
|
end
|
|
1450
1754
|
|
|
1451
1755
|
# POST /api/sessions/:id/archive: hide it (and its delegates) from the
|
|
@@ -1457,10 +1761,10 @@ module Samagotchi
|
|
|
1457
1761
|
(result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
|
|
1458
1762
|
json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
|
|
1459
1763
|
stopped: result[:stopped], discarded: result[:discarded] })
|
|
1460
|
-
rescue SessionManager::OwnedByTUI
|
|
1461
|
-
owned_by_repl(id)
|
|
1764
|
+
rescue SessionManager::OwnedByTUI => e
|
|
1765
|
+
owned_by_repl(id, e)
|
|
1462
1766
|
rescue SessionManager::ArchiveRefused => e
|
|
1463
|
-
|
|
1767
|
+
archive_refused(e)
|
|
1464
1768
|
end
|
|
1465
1769
|
|
|
1466
1770
|
# POST /api/sessions/:id/unarchive: back to the lists, delegates too.
|
|
@@ -1470,17 +1774,21 @@ module Samagotchi
|
|
|
1470
1774
|
json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
|
|
1471
1775
|
end
|
|
1472
1776
|
|
|
1473
|
-
# DELETE /api/sessions/:id: the session for good.
|
|
1474
|
-
# stopped first (the page
|
|
1475
|
-
# refused
|
|
1476
|
-
|
|
1477
|
-
|
|
1777
|
+
# DELETE /api/sessions/:id?force=true: the session for good. With
|
|
1778
|
+
# force a live worker is stopped first (the page sends it: its confirm
|
|
1779
|
+
# says so); without it a live worker's session is refused, 409
|
|
1780
|
+
# worker_running, as chi sessions delete without --force. A chi REPL's
|
|
1781
|
+
# session is always refused.
|
|
1782
|
+
def handle_delete(req, id)
|
|
1783
|
+
result = @manager.delete_session(id, state_dir: @state_dir, stop: truthy_param?(req.params["force"]),
|
|
1784
|
+
wait: STOP_WAIT_SECONDS)
|
|
1478
1785
|
@hub&.touch(result[:id])
|
|
1479
1786
|
json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
|
|
1480
|
-
rescue SessionManager::OwnedByTUI
|
|
1481
|
-
owned_by_repl(id)
|
|
1787
|
+
rescue SessionManager::OwnedByTUI => e
|
|
1788
|
+
owned_by_repl(id, e)
|
|
1482
1789
|
rescue SessionManager::DeleteRefused => e
|
|
1483
|
-
|
|
1790
|
+
detail = e.reason == :still_stopping ? "#{e.message}; try again in a moment" : e.message
|
|
1791
|
+
refusal_response(409, e.reason.to_s, e, detail: detail, session_id: e.session_id, reason: e.reason.to_s)
|
|
1484
1792
|
end
|
|
1485
1793
|
|
|
1486
1794
|
# ── The working set (web.working_set) ───────────────────────────────
|
|
@@ -1510,7 +1818,8 @@ module Samagotchi
|
|
|
1510
1818
|
llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
|
|
1511
1819
|
SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
|
|
1512
1820
|
end
|
|
1513
|
-
snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context)
|
|
1821
|
+
snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context),
|
|
1822
|
+
pins: session.working_set_pins)
|
|
1514
1823
|
json_response(200, snapshot.to_h.merge(running: running))
|
|
1515
1824
|
end
|
|
1516
1825
|
|
|
@@ -1536,29 +1845,44 @@ module Samagotchi
|
|
|
1536
1845
|
# provider (ContextProviders): never a command.
|
|
1537
1846
|
|
|
1538
1847
|
# GET /api/sessions/:id/context: the sources this session sees, muted
|
|
1539
|
-
# ones marked, with what the chips show.
|
|
1540
|
-
|
|
1848
|
+
# ones marked, with what the chips show, never a command. ?full=1
|
|
1849
|
+
# (chi context ls from another machine, SessionAccess::Remote): each
|
|
1850
|
+
# source as it was added too, its command included (#context_row), and
|
|
1851
|
+
# a project's source a session's own of its name shadows, marked
|
|
1852
|
+
# shadowed.
|
|
1853
|
+
def handle_context_list(req, id)
|
|
1541
1854
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1542
1855
|
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1543
1856
|
subs = own.subscriptions
|
|
1544
|
-
|
|
1857
|
+
full = truthy_param?(req.params["full"])
|
|
1858
|
+
rows = attached_context(session, shadowed: full).map { |attached| context_row(attached, own, subs, full: full) }
|
|
1545
1859
|
json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
|
|
1546
1860
|
end
|
|
1547
1861
|
|
|
1548
|
-
# GET /api/sessions/:id/context/:name: one source with its text
|
|
1549
|
-
|
|
1862
|
+
# GET /api/sessions/:id/context/:name: one source with its text
|
|
1863
|
+
# (?full=1: as the list's).
|
|
1864
|
+
def handle_context_show(req, id, name)
|
|
1550
1865
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1551
1866
|
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1552
1867
|
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
#
|
|
1868
|
+
row = context_row(attached, own, own.subscriptions, full: truthy_param?(req.params["full"]))
|
|
1869
|
+
json_response(200, row.merge(text: attached.snapshot.text))
|
|
1870
|
+
end
|
|
1871
|
+
|
|
1872
|
+
# POST /api/sessions/:id/context: a source attached to this session.
|
|
1873
|
+
# {url, why, hint}: the one an installed bundle's provider makes of
|
|
1874
|
+
# +url+ (the page's only kind). As chi context add: {name, push: true,
|
|
1875
|
+
# why, hint}, a source chi context push fills, or {name, cmd, every,
|
|
1876
|
+
# why, hint}, a command chi runs every so often. added_by: "cli" or
|
|
1877
|
+
# "agent" (chi context's), else "web". 201 {status: "attached", name};
|
|
1878
|
+
# 400 invalid_json or invalid_source (a bad name or every), 422
|
|
1879
|
+
# not_a_url or no_provider, 409 exists.
|
|
1558
1880
|
def handle_context_add(req, id)
|
|
1559
1881
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1560
1882
|
body = parse_json(request_body(req))
|
|
1561
|
-
return error_response(400, "invalid_json",
|
|
1883
|
+
return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body.is_a?(Hash)
|
|
1884
|
+
return context_add_named(id, body) if body.key?("name")
|
|
1885
|
+
return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body["url"].is_a?(String)
|
|
1562
1886
|
|
|
1563
1887
|
url = body["url"].strip
|
|
1564
1888
|
return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
|
|
@@ -1567,7 +1891,7 @@ module Samagotchi
|
|
|
1567
1891
|
return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
|
|
1568
1892
|
|
|
1569
1893
|
own = ContextSources.session_location(session.id, state_dir: default_state_dir)
|
|
1570
|
-
own.add(web_source(resolved, body
|
|
1894
|
+
own.add(web_source(resolved, body, own.scope))
|
|
1571
1895
|
json_response(201, { status: "attached", name: resolved.name })
|
|
1572
1896
|
rescue ContextProviders::Invalid => e
|
|
1573
1897
|
error_response(422, "no_provider", e.message)
|
|
@@ -1575,16 +1899,49 @@ module Samagotchi
|
|
|
1575
1899
|
error_response(409, "exists", e.message)
|
|
1576
1900
|
end
|
|
1577
1901
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1902
|
+
CONTEXT_ADD_BODIES = "give {url, why}, {name, push: true, why, hint} or {name, cmd, every, why, hint}"
|
|
1903
|
+
|
|
1904
|
+
# A --push or --cmd source (chi context add NAME), checked as chi
|
|
1905
|
+
# context checks it, attached through SessionAccess::Local.
|
|
1906
|
+
def context_add_named(id, body)
|
|
1907
|
+
name, cmd, push, every = body.values_at("name", "cmd", "push", "every")
|
|
1908
|
+
unless name.is_a?(String) && ((push == true) ^ cmd.is_a?(String)) && (every.nil? || every.is_a?(Integer))
|
|
1909
|
+
return error_response(400, "invalid_json", CONTEXT_ADD_BODIES)
|
|
1910
|
+
end
|
|
1911
|
+
return error_response(400, "invalid_source", "every is for a cmd source") if push && every
|
|
1912
|
+
return error_response(400, "invalid_source", "cmd is empty") if cmd && cmd.strip.empty?
|
|
1913
|
+
|
|
1914
|
+
source = begin
|
|
1915
|
+
ContextSources.check_name!(name)
|
|
1916
|
+
ContextSources::Source.new(name: name, cmd: cmd, every_seconds: ContextSources.check_every!(every),
|
|
1917
|
+
why: context_line(body["why"]), hint: context_line(body["hint"]), scope: nil,
|
|
1918
|
+
added_by: context_added_by(body), created_at: Time.now.utc.iso8601)
|
|
1919
|
+
rescue ContextSources::Invalid => e
|
|
1920
|
+
return error_response(400, "invalid_source", e.message)
|
|
1921
|
+
end
|
|
1922
|
+
session_access.context_add(context_target(id), source)
|
|
1923
|
+
json_response(201, { status: "attached", name: name })
|
|
1924
|
+
end
|
|
1925
|
+
|
|
1926
|
+
def context_line(value) = ContextSources.one_line(value.is_a?(String) ? value : nil, ContextSources::LINE_MAX_CHARS)
|
|
1927
|
+
def context_added_by(body) = %w[cli agent].include?(body["added_by"]) ? body["added_by"] : "web"
|
|
1928
|
+
|
|
1929
|
+
def web_source(resolved, body, scope)
|
|
1930
|
+
ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
|
|
1931
|
+
why: context_line(body["why"]) || resolved.why,
|
|
1932
|
+
hint: context_line(body["hint"]) || resolved.hint, scope: scope,
|
|
1933
|
+
added_by: context_added_by(body), created_at: Time.now.utc.iso8601,
|
|
1582
1934
|
provider: resolved.bundle)
|
|
1583
1935
|
end
|
|
1584
1936
|
|
|
1585
1937
|
# DELETE /api/sessions/:id/context/:name: the session's own source is
|
|
1586
|
-
# detached; a project's is muted for this session only
|
|
1587
|
-
|
|
1938
|
+
# detached; a project's is muted for this session only (the page's
|
|
1939
|
+
# ×). With ?own=1 (chi context rm) only the session's own is
|
|
1940
|
+
# detached: a project's is 409 project_source, as chi context rm
|
|
1941
|
+
# refuses it.
|
|
1942
|
+
def handle_context_delete(req, id, name)
|
|
1943
|
+
return context_remove_own(id, name) if truthy_param?(req.params["own"])
|
|
1944
|
+
|
|
1588
1945
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1589
1946
|
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1590
1947
|
if attached.location.session?
|
|
@@ -1596,8 +1953,20 @@ module Samagotchi
|
|
|
1596
1953
|
end
|
|
1597
1954
|
end
|
|
1598
1955
|
|
|
1599
|
-
def
|
|
1600
|
-
|
|
1956
|
+
def context_remove_own(id, name)
|
|
1957
|
+
case session_access.context_remove(context_target(id), name)
|
|
1958
|
+
when :removed then json_response(200, { status: "detached", name: name })
|
|
1959
|
+
when :project
|
|
1960
|
+
error_response(409, "project_source", ContextCommand.project_source_message(name))
|
|
1961
|
+
else error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1962
|
+
end
|
|
1963
|
+
rescue ContextSources::Invalid
|
|
1964
|
+
error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
def attached_context(session, shadowed: false)
|
|
1968
|
+
ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir,
|
|
1969
|
+
shadowed: shadowed)
|
|
1601
1970
|
end
|
|
1602
1971
|
|
|
1603
1972
|
# @return [ContextSources::Attached, nil]
|
|
@@ -1608,13 +1977,20 @@ module Samagotchi
|
|
|
1608
1977
|
nil
|
|
1609
1978
|
end
|
|
1610
1979
|
|
|
1611
|
-
|
|
1980
|
+
# A source's row: what the page's chips show. +full+ (chi context ls
|
|
1981
|
+
# and show from another machine, SessionAccess::Remote): also the
|
|
1982
|
+
# source as it was added (source: Source#to_h, its command too) and
|
|
1983
|
+
# the snapshot (Snapshot#to_h without its text), and shadowed.
|
|
1984
|
+
def context_row(attached, own, subs, full: false)
|
|
1612
1985
|
source = attached.source
|
|
1613
1986
|
snapshot = attached.snapshot
|
|
1614
|
-
{ name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1987
|
+
row = { name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
|
|
1988
|
+
why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
|
|
1989
|
+
fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
|
|
1990
|
+
unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
|
|
1991
|
+
return row unless full
|
|
1992
|
+
|
|
1993
|
+
row.merge(source: source.to_h, snapshot: snapshot.to_h.except("text", :text), shadowed: attached.shadowed)
|
|
1618
1994
|
end
|
|
1619
1995
|
|
|
1620
1996
|
def handle_stream(req, id)
|
|
@@ -1654,9 +2030,25 @@ module Samagotchi
|
|
|
1654
2030
|
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1655
2031
|
end
|
|
1656
2032
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
2033
|
+
[200, headers, proxy_stream_body(id, req, bridge_port)]
|
|
2034
|
+
end
|
|
2035
|
+
|
|
2036
|
+
# The one ProxyStreamBody both of #handle_stream's transports send:
|
|
2037
|
+
# the session's Bridge stream, through the request principal's frame
|
|
2038
|
+
# filter (#stream_filter).
|
|
2039
|
+
def proxy_stream_body(id, req, port)
|
|
2040
|
+
ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: stream_query(req), headers: req.env,
|
|
2041
|
+
server_running: stream_running(principal(req)), filter: stream_filter(principal(req)))
|
|
2042
|
+
end
|
|
2043
|
+
|
|
2044
|
+
# What a principal's stream goes through on its way from the Bridge:
|
|
2045
|
+
# nil, the raw bytes unchanged, for the owner (the only principal
|
|
2046
|
+
# today). A filter is anything with #call(chunk) -> String (nil or
|
|
2047
|
+
# "" sends nothing); see ProxyStreamBody.
|
|
2048
|
+
def stream_filter(principal)
|
|
2049
|
+
return nil if principal.owner?
|
|
2050
|
+
|
|
2051
|
+
raise ArgumentError, "no stream filter for a #{principal.kind} principal"
|
|
1660
2052
|
end
|
|
1661
2053
|
|
|
1662
2054
|
# GET /api/events: the session list as one SSE stream per tab. The
|
|
@@ -1716,10 +2108,10 @@ module Samagotchi
|
|
|
1716
2108
|
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
1717
2109
|
# only body bytes go to the socket here.
|
|
1718
2110
|
def sse_hijack(id, req, port)
|
|
2111
|
+
# Made before the status line goes out: a principal with no filter
|
|
2112
|
+
# is refused (#stream_filter), never streamed.
|
|
2113
|
+
body = proxy_stream_body(id, req, port)
|
|
1719
2114
|
lambda do |io|
|
|
1720
|
-
query = stream_query(req)
|
|
1721
|
-
body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
|
|
1722
|
-
server_running: @server_running)
|
|
1723
2115
|
body.each { |chunk| io.write(chunk) }
|
|
1724
2116
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
1725
2117
|
nil # client went away — end the stream quietly
|
|
@@ -1777,6 +2169,8 @@ module Samagotchi
|
|
|
1777
2169
|
end
|
|
1778
2170
|
|
|
1779
2171
|
def serve_index(req)
|
|
2172
|
+
return participant_page(principal(req)) unless principal(req).owner?
|
|
2173
|
+
|
|
1780
2174
|
path = File.join(@public_dir, "index.html")
|
|
1781
2175
|
if File.file?(path)
|
|
1782
2176
|
body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
|
|
@@ -2323,15 +2717,20 @@ module Samagotchi
|
|
|
2323
2717
|
# ends when the Bridge closes the stream or, as EventsBody, when the
|
|
2324
2718
|
# server leaves :Running: rackup's WEBrick joins every request thread
|
|
2325
2719
|
# before returning, and a read blocked on a quiet worker would hang
|
|
2326
|
-
# Ctrl-C of chi web.
|
|
2720
|
+
# Ctrl-C of chi web. Without a +filter+ the bytes go through unchanged
|
|
2721
|
+
# (the owner's stream); with one, each chunk read from the Bridge goes
|
|
2722
|
+
# through it, and what it returns is sent.
|
|
2327
2723
|
class ProxyStreamBody
|
|
2328
2724
|
# @param headers [Hash] the request's Rack env; only its Last-Event-ID
|
|
2329
2725
|
# is kept (the env carries the token cookie, never held or inspected)
|
|
2330
|
-
|
|
2726
|
+
# @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
|
|
2727
|
+
# it sees the Bridge's reads as they come, not whole frames
|
|
2728
|
+
def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true }, filter: nil)
|
|
2331
2729
|
@client = BridgeClient.new(session_id: session_id, port: port, host: host)
|
|
2332
2730
|
@query = query
|
|
2333
2731
|
@last_event_id = headers["HTTP_LAST_EVENT_ID"]
|
|
2334
2732
|
@server_running = server_running
|
|
2733
|
+
@filter = filter
|
|
2335
2734
|
end
|
|
2336
2735
|
|
|
2337
2736
|
def each(&)
|
|
@@ -2339,7 +2738,12 @@ module Samagotchi
|
|
|
2339
2738
|
# Last-Event-ID header over ?from_seq, and the reconnect URL carries a
|
|
2340
2739
|
# stale initial cursor — without this the bridge would replay content
|
|
2341
2740
|
# already delivered (duplicate bubbles).
|
|
2342
|
-
@client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
|
|
2741
|
+
return @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &) unless @filter
|
|
2742
|
+
|
|
2743
|
+
@client.stream(query: @query, last_event_id: @last_event_id, running: @server_running) do |chunk|
|
|
2744
|
+
out = @filter.call(chunk)
|
|
2745
|
+
yield(out) unless out.nil? || out.empty?
|
|
2746
|
+
end
|
|
2343
2747
|
end
|
|
2344
2748
|
end
|
|
2345
2749
|
end
|