samagotchi 0.170.0 → 0.171.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 +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +37 -23
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +2 -2
- data/docs/internals/bridge.md +5 -4
- data/docs/internals/context-telemetry.md +13 -1
- data/docs/internals/engine.md +4 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +1 -1
- data/docs/internals/participants.md +235 -119
- data/docs/internals/web-frontend.md +164 -33
- data/docs/internals/working-set.md +69 -5
- data/docs/releasing.md +2 -1
- data/docs/sessions.md +6 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
- data/lib/samagotchi/bridge.rb +39 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +13 -0
- data/lib/samagotchi/engine.rb +5 -1
- data/lib/samagotchi/event_fanout.rb +22 -1
- data/lib/samagotchi/grants.rb +5 -0
- data/lib/samagotchi/guardrails/review_paths.rb +2 -2
- data/lib/samagotchi/image_store.rb +51 -12
- data/lib/samagotchi/kernel_loop.rb +7 -1
- data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
- data/lib/samagotchi/llm_context_stale.rb +4 -1
- data/lib/samagotchi/participant_messages.rb +39 -0
- data/lib/samagotchi/participant_revise.rb +61 -0
- data/lib/samagotchi/participant_uploads.rb +98 -0
- data/lib/samagotchi/reply_wait/poll.rb +5 -1
- data/lib/samagotchi/revise_anchor.rb +9 -0
- data/lib/samagotchi/send_command.rb +5 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager/delivery.rb +7 -0
- data/lib/samagotchi/session_stores.rb +2 -0
- data/lib/samagotchi/tool_runner.rb +39 -5
- data/lib/samagotchi/tools/memory.rb +26 -7
- data/lib/samagotchi/turn_anchors.rb +0 -3
- data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
- data/lib/samagotchi/verify_anchor.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -77
- data/lib/samagotchi/web/participant_access.rb +129 -26
- data/lib/samagotchi/web/participant_images.rb +185 -0
- data/lib/samagotchi/web/participant_review.rb +19 -138
- data/lib/samagotchi/web/participant_show.rb +240 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
- data/lib/samagotchi/web/participant_view.rb +331 -24
- data/lib/samagotchi/web/public/annotate_bar.css +1 -2
- data/lib/samagotchi/web/public/annotate_bar.js +1 -2
- data/lib/samagotchi/web/public/app.js +124 -88
- data/lib/samagotchi/web/public/block_host.js +7 -4
- data/lib/samagotchi/web/public/bubbles.js +9 -1
- data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
- data/lib/samagotchi/web/public/chip_popover.js +5 -4
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer.css +4 -6
- data/lib/samagotchi/web/public/composer_mode.js +11 -6
- data/lib/samagotchi/web/public/composer_mount.js +4 -4
- data/lib/samagotchi/web/public/ctx.js +12 -3
- data/lib/samagotchi/web/public/delivery_line.js +34 -5
- data/lib/samagotchi/web/public/history_shape.js +5 -1
- data/lib/samagotchi/web/public/images.js +7 -1
- data/lib/samagotchi/web/public/index.html +40 -4
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +5 -4
- data/lib/samagotchi/web/public/owner_claim.js +3 -3
- data/lib/samagotchi/web/public/owner_verify.js +0 -2
- data/lib/samagotchi/web/public/participant_bar.js +108 -0
- data/lib/samagotchi/web/public/participant_main.js +289 -0
- data/lib/samagotchi/web/public/participant_model.js +5 -292
- data/lib/samagotchi/web/public/participant_names.js +6 -1
- data/lib/samagotchi/web/public/participant_revise.js +66 -0
- data/lib/samagotchi/web/public/participants_panel.js +3 -12
- data/lib/samagotchi/web/public/question_cards.js +14 -3
- data/lib/samagotchi/web/public/refs.js +53 -36
- data/lib/samagotchi/web/public/reload_check.js +68 -11
- data/lib/samagotchi/web/public/revise_stack.js +21 -10
- data/lib/samagotchi/web/public/send_turn.js +24 -6
- data/lib/samagotchi/web/public/session_fields.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +12 -4
- data/lib/samagotchi/web/public/session_sync.js +9 -2
- data/lib/samagotchi/web/public/session_view.js +17 -7
- data/lib/samagotchi/web/public/stage_asking.js +6 -4
- data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
- data/lib/samagotchi/web/public/stage_view.js +18 -4
- data/lib/samagotchi/web/public/stage_waiting.js +74 -6
- data/lib/samagotchi/web/public/storage.js +22 -0
- data/lib/samagotchi/web/public/stream_handlers.js +2 -1
- data/lib/samagotchi/web/public/tokens.css +1 -1
- data/lib/samagotchi/web/public/turn_block.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -6
- data/lib/samagotchi/web/public/turn_html.js +9 -2
- data/lib/samagotchi/web/public/turn_range.js +1 -2
- data/lib/samagotchi/web/public/verify_target.js +2 -4
- data/lib/samagotchi/web/public/viewer.js +59 -0
- data/lib/samagotchi/web/public/viewer_self.js +53 -0
- data/lib/samagotchi/web/public/working_set.js +11 -5
- data/lib/samagotchi/web/public/working_set_view.js +53 -8
- data/lib/samagotchi/web/public/ws_cards.js +89 -0
- data/lib/samagotchi/web/public/ws_peek.js +106 -0
- data/lib/samagotchi/web/public/ws_preview.css +126 -0
- data/lib/samagotchi/web/public/ws_reader.js +319 -0
- data/lib/samagotchi/web/public/ws_timeline.js +200 -0
- data/lib/samagotchi/web/public/ws_way_in.js +25 -0
- data/lib/samagotchi/web/session_control.rb +39 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +75 -28
- data/lib/samagotchi/web/viewer.rb +70 -0
- data/lib/samagotchi/web/working_set_routes.rb +132 -0
- data/lib/samagotchi/worker.rb +19 -14
- data/lib/samagotchi/working_set.rb +260 -8
- metadata +20 -6
- data/lib/samagotchi/web/public/participant.html +0 -218
- data/lib/samagotchi/web/public/participant.js +0 -719
- data/lib/samagotchi/web/public/participant_annotate.js +0 -76
- data/lib/samagotchi/web/public/review_history.js +0 -72
- data/lib/samagotchi/web/public/tools_overview.js +0 -42
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -69,15 +69,19 @@ require_relative "session_hub"
|
|
|
69
69
|
require_relative "project_list"
|
|
70
70
|
require_relative "session_access_routes"
|
|
71
71
|
require_relative "principal"
|
|
72
|
+
require_relative "viewer"
|
|
72
73
|
require_relative "participant_access"
|
|
74
|
+
require_relative "participant_images"
|
|
73
75
|
require_relative "participants_admin"
|
|
74
76
|
require_relative "participant_review"
|
|
77
|
+
require_relative "participant_show"
|
|
75
78
|
require_relative "message_display"
|
|
76
79
|
require_relative "session_show"
|
|
77
80
|
require_relative "session_create"
|
|
78
81
|
require_relative "session_control"
|
|
79
82
|
require_relative "verify_fresh_routes"
|
|
80
83
|
require_relative "reload_check_route"
|
|
84
|
+
require_relative "working_set_routes"
|
|
81
85
|
require_relative "push_routes"
|
|
82
86
|
require_relative "block_renderer_routes"
|
|
83
87
|
require_relative "update_check"
|
|
@@ -105,14 +109,17 @@ module Samagotchi
|
|
|
105
109
|
include RedactedInspect
|
|
106
110
|
include SessionAccessRoutes
|
|
107
111
|
include ParticipantAccess
|
|
112
|
+
include ParticipantImages
|
|
108
113
|
include ParticipantsAdmin
|
|
109
114
|
include ParticipantReview
|
|
115
|
+
include ParticipantShow
|
|
110
116
|
include MessageDisplay
|
|
111
117
|
include SessionShow
|
|
112
118
|
include SessionCreate
|
|
113
119
|
include SessionControl
|
|
114
120
|
include VerifyFreshRoutes
|
|
115
121
|
include ReloadCheckRoute
|
|
122
|
+
include WorkingSetRoutes
|
|
116
123
|
include PushRoutes
|
|
117
124
|
include BlockRendererRoutes
|
|
118
125
|
include ProxyGate
|
|
@@ -223,6 +230,7 @@ module Samagotchi
|
|
|
223
230
|
@update_ops = update_ops || UpdateOps.new(hub: hub, manager: @manager, state_dir: state_dir,
|
|
224
231
|
runner: update_runner, blocked: update_blocked)
|
|
225
232
|
init_participant_access(grants, state_dir)
|
|
233
|
+
init_participant_images
|
|
226
234
|
init_proxy_gate(public_url, public_token)
|
|
227
235
|
end
|
|
228
236
|
|
|
@@ -283,9 +291,8 @@ module Samagotchi
|
|
|
283
291
|
["POST", %r{\A/api/update/restart_web\z}, :handle_restart_web, :owner],
|
|
284
292
|
["POST", %r{\A/api/render\z}, :handle_render, :owner],
|
|
285
293
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :participant],
|
|
286
|
-
["GET", %r{\A/api/sessions/([^/]+)/review\z}, :handle_review, :participant, :session],
|
|
287
294
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :owner, :session],
|
|
288
|
-
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :
|
|
295
|
+
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :participant, :session],
|
|
289
296
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
|
|
290
297
|
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
|
|
291
298
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
|
|
@@ -311,14 +318,15 @@ module Samagotchi
|
|
|
311
318
|
["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer, :owner],
|
|
312
319
|
["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss, :owner],
|
|
313
320
|
["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :owner, :session],
|
|
314
|
-
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload, :
|
|
315
|
-
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :
|
|
321
|
+
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload, :participant, :session],
|
|
322
|
+
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :participant, :session],
|
|
316
323
|
["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :owner, :session],
|
|
317
324
|
["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :owner, :session],
|
|
318
325
|
["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :owner, :session],
|
|
319
326
|
["POST", %r{\A/api/sessions/([^/]+)/context/([^/]+)/push\z}, :handle_context_push, :owner, :session],
|
|
320
327
|
["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :owner, :session],
|
|
321
|
-
["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :
|
|
328
|
+
["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :participant, :session],
|
|
329
|
+
["GET", %r{\A/api/sessions/([^/]+)/working_set/item\z}, :handle_working_set_item, :owner, :session],
|
|
322
330
|
["GET", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_list, :owner, :session],
|
|
323
331
|
["POST", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_invite, :owner, :session],
|
|
324
332
|
["DELETE", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_revoke, :owner, :session],
|
|
@@ -1387,69 +1395,6 @@ module Samagotchi
|
|
|
1387
1395
|
end
|
|
1388
1396
|
end
|
|
1389
1397
|
|
|
1390
|
-
# ── The working set (web.working_set) ───────────────────────────────
|
|
1391
|
-
|
|
1392
|
-
# GET /api/sessions/:id/working_set: the working set as the model has
|
|
1393
|
-
# it ({items:, at:, running:}, WorkingSet), what the page overlays on its rows'
|
|
1394
|
-
# set when it seeds a session's and when the expanded view opens (a
|
|
1395
|
-
# file changed while the session sat idle). A live worker's snapshot
|
|
1396
|
-
# messages are the truth, as #handle_show's, else the file's; the
|
|
1397
|
-
# strategy is the session's llm_context summary's, the folder its working
|
|
1398
|
-
# directory. running: a turn runs. The snapshot's messages lag it, so
|
|
1399
|
-
# the answer is then the turn's last :working_set_updated (the
|
|
1400
|
-
# snapshot's working_set, TurnAccumulator#working_set); before the
|
|
1401
|
-
# turn emitted one, or from an older worker without the field, it is
|
|
1402
|
-
# the session's messages, behind that turn's events, and the page
|
|
1403
|
-
# keeps theirs (working_set.js newerSnapshot).
|
|
1404
|
-
def handle_working_set(_req, id)
|
|
1405
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1406
|
-
live = bridge_get_json(id, "snapshot")
|
|
1407
|
-
worker_snapshot = live && live["snapshot"]
|
|
1408
|
-
running = !worker_snapshot&.dig("current_turn").nil?
|
|
1409
|
-
turn_set = running && running_working_set(live["snapshot"]["working_set"])
|
|
1410
|
-
return json_response(200, turn_set.merge(running: true)) if turn_set
|
|
1411
|
-
|
|
1412
|
-
idle_set = idle_working_set(worker_snapshot && worker_snapshot["idle_working_set"]) unless running
|
|
1413
|
-
return json_response(200, idle_set.merge(running: false)) if idle_set
|
|
1414
|
-
|
|
1415
|
-
messages = live_or_saved(worker_snapshot, "messages") { session.messages }
|
|
1416
|
-
# A snapshot's entries have string keys (paired_runs reads symbols).
|
|
1417
|
-
messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
|
|
1418
|
-
llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
|
|
1419
|
-
SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
|
|
1420
|
-
end
|
|
1421
|
-
snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context),
|
|
1422
|
-
pins: session.working_set_pins)
|
|
1423
|
-
json_response(200, snapshot.to_h.merge(running: running))
|
|
1424
|
-
end
|
|
1425
|
-
|
|
1426
|
-
# The running turn's set from a worker's snapshot ({"items", "at"}), as
|
|
1427
|
-
# {items:, at:}; nil when it has none (none emitted yet, an older worker).
|
|
1428
|
-
def running_working_set(set)
|
|
1429
|
-
return nil unless set.is_a?(Hash) && set["items"].is_a?(Array) && set["at"].is_a?(Integer)
|
|
1430
|
-
|
|
1431
|
-
{ items: set["items"], at: set["at"] }
|
|
1432
|
-
end
|
|
1433
|
-
|
|
1434
|
-
# The worker's between-turns working set ({"items", "at", optional
|
|
1435
|
-
# "adds"}); nil for an older or malformed snapshot.
|
|
1436
|
-
def idle_working_set(set)
|
|
1437
|
-
return nil unless set.is_a?(Hash) && set["items"].is_a?(Array) && set["at"].is_a?(Integer)
|
|
1438
|
-
return nil if set.key?("adds") && !set["adds"].is_a?(Array)
|
|
1439
|
-
|
|
1440
|
-
{ items: set["items"], at: set["at"] }.tap do |snapshot|
|
|
1441
|
-
snapshot[:adds] = set["adds"] if set.key?("adds")
|
|
1442
|
-
end
|
|
1443
|
-
end
|
|
1444
|
-
|
|
1445
|
-
# The strategy an llm_context summary names ("none", "stale,forget"), as
|
|
1446
|
-
# LLMContextView takes it; none when there is no chip.
|
|
1447
|
-
def working_set_strategy(llm_context)
|
|
1448
|
-
named = llm_context.is_a?(Hash) ? (llm_context[:strategy] || llm_context["strategy"]).to_s : ""
|
|
1449
|
-
layers = named.split(",").map(&:strip).reject { |layer| layer.empty? || layer == "none" }
|
|
1450
|
-
layers.empty? ? LLMContextView::NONE : layers.map(&:to_sym)
|
|
1451
|
-
end
|
|
1452
|
-
|
|
1453
1398
|
# ── Attached context (ContextSources): the session bar's chips ───────
|
|
1454
1399
|
# A source's command isn't shown (the page may be on a phone over the
|
|
1455
1400
|
# LAN), and the web adds a URL only, through an installed bundle's
|
|
@@ -1679,9 +1624,8 @@ module Samagotchi
|
|
|
1679
1624
|
end
|
|
1680
1625
|
|
|
1681
1626
|
# What a request's stream goes through on its way from the Bridge:
|
|
1682
|
-
# nil, the raw bytes unchanged, for the owner; a participant's
|
|
1683
|
-
#
|
|
1684
|
-
# ParticipantStreamFilter. A filter is anything with #call(chunk) ->
|
|
1627
|
+
# nil, the raw bytes unchanged, for the owner; a participant's goes
|
|
1628
|
+
# through ParticipantStreamFilter. A filter is anything with #call(chunk) ->
|
|
1685
1629
|
# String (nil or "" sends nothing); see ProxyStreamBody.
|
|
1686
1630
|
def stream_filter(req, id)
|
|
1687
1631
|
return nil unless participant_view?(req)
|
|
@@ -1839,8 +1783,6 @@ module Samagotchi
|
|
|
1839
1783
|
end
|
|
1840
1784
|
|
|
1841
1785
|
def serve_index(req)
|
|
1842
|
-
return participant_shell(req) if participant_view?(req)
|
|
1843
|
-
|
|
1844
1786
|
path = File.join(@public_dir, "index.html")
|
|
1845
1787
|
if File.file?(path)
|
|
1846
1788
|
# A block: a preset's \& or \' is text, never a backreference.
|
|
@@ -1956,17 +1898,21 @@ module Samagotchi
|
|
|
1956
1898
|
# #code= and the app's start_url "/" all land there). ?dir= beats
|
|
1957
1899
|
# ?scope=all.
|
|
1958
1900
|
def index_data_attributes(req)
|
|
1901
|
+
return participant_index_data(req) unless principal(req).owner?
|
|
1902
|
+
|
|
1959
1903
|
# version: the chi that served the page; the events snapshot names
|
|
1960
1904
|
# the one serving now (an upgraded chi web after a reconnect).
|
|
1961
1905
|
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
|
|
1962
|
-
# The owner's home: a claim link's quote reads it "~", as
|
|
1963
|
-
# participant page shows it (claim_link.js).
|
|
1906
|
+
# The owner's home: a claim link's quote reads it "~", as a
|
|
1907
|
+
# participant's page shows it (claim_link.js).
|
|
1964
1908
|
attrs["home"] = Dir.home
|
|
1965
1909
|
# Only when on: the page reads its presence.
|
|
1966
1910
|
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
1967
1911
|
# The opt-in reload check (reload_check.js): only when on.
|
|
1968
1912
|
attrs["reload-check"] = "1" if @reload_check
|
|
1969
1913
|
attrs.merge!(annotate_presets_data)
|
|
1914
|
+
# Who looks and what they may do (Viewer; viewer.js reads it at boot).
|
|
1915
|
+
attrs.merge!(Viewer.for(principal(req)).body_attributes)
|
|
1970
1916
|
dir, = scope_dir(req.params["dir"])
|
|
1971
1917
|
unless dir
|
|
1972
1918
|
dir = default_scope_dir(req)
|
|
@@ -1989,11 +1935,30 @@ module Samagotchi
|
|
|
1989
1935
|
body_data(attrs)
|
|
1990
1936
|
end
|
|
1991
1937
|
|
|
1938
|
+
# A participant's <body> data for index.html: who they are (Viewer), their
|
|
1939
|
+
# grant's session and its participant title (the tab's), the version
|
|
1940
|
+
# and the Annotate presets, and whether the working set is on
|
|
1941
|
+
# (web.working_set: their stage's file column, as the owner's).
|
|
1942
|
+
# Nothing of the owner's machine: no home, folders, project or way
|
|
1943
|
+
# back (sessions-dir, server-dir, home, scope-dir, project-*, back-*).
|
|
1944
|
+
def participant_index_data(req)
|
|
1945
|
+
grant = principal(req).grant
|
|
1946
|
+
attrs = { "version" => Samagotchi::VERSION, "session" => grant.session_id }
|
|
1947
|
+
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
1948
|
+
title = Degrade.call(:web, "participant_title_unreadable", fallback: nil) do
|
|
1949
|
+
participant_title(@session_class.load(grant.session_id, state_dir: default_state_dir))
|
|
1950
|
+
end
|
|
1951
|
+
attrs["session-title"] = title if title
|
|
1952
|
+
attrs.merge!(annotate_presets_data)
|
|
1953
|
+
attrs.merge!(Viewer.for(principal(req)).body_attributes)
|
|
1954
|
+
body_data(attrs)
|
|
1955
|
+
end
|
|
1956
|
+
|
|
1992
1957
|
# +attrs+ as <body> data attributes, each value escaped.
|
|
1993
1958
|
def body_data(attrs) = attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
|
|
1994
1959
|
|
|
1995
|
-
# The Annotate presets (web.annotate_presets) for
|
|
1996
|
-
# owner's and
|
|
1960
|
+
# The Annotate presets (web.annotate_presets) for the page's <body>,
|
|
1961
|
+
# the owner's and a participant's. Always there: an empty one means
|
|
1997
1962
|
# no presets, not the default.
|
|
1998
1963
|
def annotate_presets_data = { "annotate-presets" => @annotate_presets }
|
|
1999
1964
|
|
|
@@ -11,9 +11,12 @@ require_relative "../session_inbox"
|
|
|
11
11
|
require_relative "../session_metrics"
|
|
12
12
|
require_relative "../parent_report"
|
|
13
13
|
require_relative "../participant_messages"
|
|
14
|
+
require_relative "../participant_mode"
|
|
15
|
+
require_relative "../participant_revise"
|
|
14
16
|
require_relative "../participants"
|
|
15
17
|
require_relative "../session"
|
|
16
18
|
require_relative "../turn_checkpoint"
|
|
19
|
+
require_relative "../turn_origin"
|
|
17
20
|
require_relative "../verify_fresh"
|
|
18
21
|
require_relative "../log"
|
|
19
22
|
|
|
@@ -254,32 +257,94 @@ module Samagotchi
|
|
|
254
257
|
.map { |entry| { id: entry.id, name: entry.name, kind: entry.kind, role: entry.role } }
|
|
255
258
|
end
|
|
256
259
|
|
|
260
|
+
# POST …/cancel from a participant: only their own running turn, named
|
|
261
|
+
# by its turn_id. The live snapshot's turn must be that turn and
|
|
262
|
+
# theirs (its author is the grant's), and the worker is asked to
|
|
263
|
+
# cancel that turn id (TurnState#cancel! compares it under its lock,
|
|
264
|
+
# 409 turn_mismatch): a turn that ended and gave way to another
|
|
265
|
+
# person's between the read and the cancel is never stopped. No
|
|
266
|
+
# turn_id (the Bridge would cancel whatever runs), or a turn that
|
|
267
|
+
# isn't theirs: 403 not_permitted, audited. The reason is the
|
|
268
|
+
# server's, never the body's.
|
|
269
|
+
def participant_cancel(req, id)
|
|
270
|
+
parsed = parse_json(request_body(req))
|
|
271
|
+
turn_id = (parsed["turn_id"] if parsed.is_a?(Hash)).to_s.strip
|
|
272
|
+
return not_permitted_for(req, principal(req)) if turn_id.empty?
|
|
273
|
+
|
|
274
|
+
live = bridge_get_json(id, "snapshot")
|
|
275
|
+
return not_live(id) unless live.is_a?(Hash)
|
|
276
|
+
|
|
277
|
+
turn = live.dig("snapshot", "current_turn")
|
|
278
|
+
return json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id }) unless turn.is_a?(Hash)
|
|
279
|
+
return not_permitted_for(req, principal(req)) unless own_running_turn?(principal(req).grant, turn, turn_id)
|
|
280
|
+
return cancel_unavailable(req) unless cancels_by_turn_id?(id)
|
|
281
|
+
|
|
282
|
+
relay_cancel(id, ->(client) { client.cancel(reason: PARTICIPANT_CANCEL_REASON, turn_id: turn_id) })
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Whether the session's live worker cancels only the turn_id it is
|
|
286
|
+
# named (Delivery::CANCEL_TURN_ID_FEATURE): an older one ignores it and
|
|
287
|
+
# would stop whatever runs, the owner's next turn too.
|
|
288
|
+
def cancels_by_turn_id?(id)
|
|
289
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
290
|
+
Array(ParticipantMode.live_features(session, default_state_dir)).include?(Delivery::CANCEL_TURN_ID_FEATURE)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# 409 cancel_unavailable, audited; nothing is relayed.
|
|
294
|
+
def cancel_unavailable(req)
|
|
295
|
+
audit_refused(req, principal(req).grant, reason: "cancel_unavailable")
|
|
296
|
+
error_response(409, "cancel_unavailable", ParticipantMessages::CANCEL_UNAVAILABLE)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Bridge::CANCEL_REASONS' "user": the person who sent it stopped it.
|
|
300
|
+
PARTICIPANT_CANCEL_REASON = "user"
|
|
301
|
+
|
|
302
|
+
# Whether the snapshot's running +turn+ is +turn_id+ and +grant+'s.
|
|
303
|
+
def own_running_turn?(grant, turn, turn_id)
|
|
304
|
+
author = turn["author"] || TurnOrigin.from(turn["origin"])&.author
|
|
305
|
+
turn["turn_id"].to_s == turn_id && author.to_s == grant.author
|
|
306
|
+
end
|
|
307
|
+
|
|
257
308
|
# ── A participant's turn ──────────────────────────────────────────
|
|
258
309
|
|
|
259
|
-
# POST /api/sessions/:id/turn from a participant:
|
|
260
|
-
# D10). Of the body only the prompt
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
310
|
+
# POST /api/sessions/:id/turn from a participant: their turn (D6,
|
|
311
|
+
# D10). Of the body only the prompt, the delivery and images they
|
|
312
|
+
# uploaded count: the author and client id are the grant's
|
|
313
|
+
# (review:<id>), no no_interrupt; the owner's prompt history never
|
|
314
|
+
# sees it. The delivery
|
|
315
|
+
# is queue or next step (none: next step): the worker puts a next-step
|
|
316
|
+
# message into a running turn only when that turn is theirs, else it
|
|
317
|
+
# runs as their own turn after it (Bridge#same_author_steer?; an older
|
|
318
|
+
# worker queues it). Refused: a line starting ! or / (403
|
|
319
|
+
# not_permitted: shell lines and commands are the owner's), a cut
|
|
320
|
+
# (403: theirs never cuts), their cap (429 cap_reached), images not
|
|
321
|
+
# theirs or a model without vision (ParticipantImages#
|
|
322
|
+
# participant_images_refusal) and their turn limit (429
|
|
323
|
+
# too_many_turns, the Bridge counts).
|
|
266
324
|
# Every answer is audited (turn: accepted or refused, why).
|
|
267
325
|
def handle_participant_turn(req, id, body)
|
|
268
326
|
grant = principal(req).grant
|
|
269
327
|
prompt = body["prompt"].to_s
|
|
270
328
|
return error_response(400, "missing_fields", "prompt is required") if prompt.strip.empty?
|
|
271
329
|
|
|
272
|
-
|
|
273
|
-
|
|
330
|
+
anchors = TurnAnchors.from(body)
|
|
331
|
+
revises = anchors.revises&.turn_id
|
|
332
|
+
reason = participant_turn_refusal(grant, prompt, body) || participant_revise_refusal(grant, id, anchors)
|
|
333
|
+
return participant_turn_refused(grant, prompt, *reason, revises: revises) if reason
|
|
334
|
+
|
|
335
|
+
reason, images = participant_images_refusal(grant, id, body["images"])
|
|
336
|
+
return participant_turn_refused(grant, prompt, *reason, revises: revises) if reason
|
|
274
337
|
|
|
275
|
-
# A Verify turn's claim
|
|
338
|
+
# A Verify turn's claim, a Revise turn's draft of their own answer.
|
|
339
|
+
delivery = Delivery.queue?(body["delivery"]) ? Delivery::QUEUE : Delivery::NEXT_STEP
|
|
276
340
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: ClientId.review(grant.id),
|
|
277
|
-
delivery:
|
|
278
|
-
|
|
341
|
+
delivery: delivery, author: grant.author,
|
|
342
|
+
**(images.empty? ? {} : { images: images }),
|
|
343
|
+
anchors: anchors,
|
|
279
344
|
pending_limit: grant.queue_limit + 1,
|
|
280
345
|
state_dir: @state_dir, manager: @manager,
|
|
281
346
|
bridge: -> { live_bridge_client(id) })
|
|
282
|
-
participant_turn_result(grant, prompt, result)
|
|
347
|
+
participant_turn_result(grant, prompt, result, images: images.empty? ? nil : images.size, revises: revises)
|
|
283
348
|
rescue SessionManager::OwnedByTUI => e
|
|
284
349
|
owned_by_tui(e)
|
|
285
350
|
end
|
|
@@ -287,11 +352,35 @@ module Samagotchi
|
|
|
287
352
|
# [status, code, detail] for a turn refused before delivery, nil to send it.
|
|
288
353
|
def participant_turn_refusal(grant, prompt, body)
|
|
289
354
|
return [403, "not_permitted", NOT_PERMITTED] if prompt.lstrip.start_with?("!", "/")
|
|
290
|
-
return [403, "not_permitted", NOT_PERMITTED]
|
|
355
|
+
return [403, "not_permitted", NOT_PERMITTED] if Delivery.cut?(body["delivery"])
|
|
291
356
|
|
|
292
357
|
participant_cap_refusal(grant)
|
|
293
358
|
end
|
|
294
359
|
|
|
360
|
+
# [status, code, detail] for a Revise draft (+anchors+' revises) they
|
|
361
|
+
# may not send, nil without one or when they may: a live worker
|
|
362
|
+
# without ReviseAnchor::PARTICIPANT_FEATURE (409 revise_unavailable:
|
|
363
|
+
# it wouldn't check it at the stamp), then ParticipantRevise over the
|
|
364
|
+
# messages a live worker's snapshot has (the session file lags it
|
|
365
|
+
# mid-turn and just after a turn's end), the file's only with no
|
|
366
|
+
# live worker: 403 revise_not_own (a missing turn answers the same),
|
|
367
|
+
# 409 revise_overtaken. The worker checks again at the stamp.
|
|
368
|
+
def participant_revise_refusal(grant, id, anchors)
|
|
369
|
+
return nil unless anchors.revises
|
|
370
|
+
|
|
371
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
372
|
+
features = ParticipantMode.live_features(session, default_state_dir)
|
|
373
|
+
if features && !features.include?(ReviseAnchor::PARTICIPANT_FEATURE)
|
|
374
|
+
return [409, "revise_unavailable", ParticipantMessages::REVISE_UNAVAILABLE]
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
messages = show_reads(id, session).raw_messages
|
|
378
|
+
case ParticipantRevise.refusal(messages, turn_id: anchors.revises.turn_id, author: grant.author)
|
|
379
|
+
when :not_own then [403, "revise_not_own", ParticipantMessages::REVISE_NOT_OWN]
|
|
380
|
+
when :overtaken then [409, "revise_overtaken", ParticipantMessages::REVISE_OVERTAKEN]
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
295
384
|
# [429, cap_reached, …] once the grant spent its cap (D7: their turns
|
|
296
385
|
# and the side requests they caused, from the session's saved
|
|
297
386
|
# analytics, and their Verify fresh children's; a running turn's
|
|
@@ -311,23 +400,36 @@ module Samagotchi
|
|
|
311
400
|
VerifyFresh.children_spend(grant.session_id, grant.author, state_dir: default_state_dir)
|
|
312
401
|
end
|
|
313
402
|
|
|
314
|
-
|
|
315
|
-
|
|
403
|
+
# +audit_detail+: what only the audit line says (no_vision's full
|
|
404
|
+
# words); +revises+: the turn id a Revise draft named (nil: none).
|
|
405
|
+
def participant_turn_refused(grant, prompt, status, code, detail, audit_detail = nil, revises: nil)
|
|
406
|
+
audit_turn(grant, prompt, status: "refused", reason: code, detail: audit_detail, revises: revises)
|
|
316
407
|
error_response(status, code, detail)
|
|
317
408
|
end
|
|
318
409
|
|
|
319
|
-
|
|
410
|
+
# +images+: how many the turn carried (nil: none), +revises+ the turn
|
|
411
|
+
# id its Revise draft named, for the audit line.
|
|
412
|
+
def participant_turn_result(grant, prompt, result, images: nil, revises: nil)
|
|
320
413
|
case result.status
|
|
321
414
|
when :accepted
|
|
322
415
|
enqueued_id = result.ack["enqueued_id"]
|
|
323
416
|
remember_participant_turn(enqueued_id, grant.id)
|
|
324
|
-
audit_turn(grant, prompt, status: "accepted", enqueued_id: enqueued_id)
|
|
325
|
-
|
|
417
|
+
audit_turn(grant, prompt, status: "accepted", enqueued_id: enqueued_id, images: images, revises: revises)
|
|
418
|
+
# What the worker says will happen (a turn of its own, or into
|
|
419
|
+
# their running turn at its next step); a file (no worker yet)
|
|
420
|
+
# runs as the next turn, which reads as a queue.
|
|
421
|
+
json_response(202, result.ack.merge("via" => result.via.to_s, "delivery" => result.ack["delivery"] || Delivery::QUEUE))
|
|
326
422
|
when :refused
|
|
327
|
-
audit_turn(grant, prompt, status: "refused", reason: result.ack["error"])
|
|
423
|
+
audit_turn(grant, prompt, status: "refused", reason: result.ack["error"], images: images, revises: revises)
|
|
424
|
+
# A worker without Revise at all: the participant's words, never
|
|
425
|
+
# the owner's "restart this session".
|
|
426
|
+
if result.ack["error"] == ReviseAnchor::UNAVAILABLE["error"]
|
|
427
|
+
return error_response(409, "revise_unavailable", ParticipantMessages::REVISE_UNAVAILABLE)
|
|
428
|
+
end
|
|
429
|
+
|
|
328
430
|
json_response(result.code, result.ack)
|
|
329
431
|
when :timeout
|
|
330
|
-
audit_turn(grant, prompt, status: "refused", reason: "worker_timeout")
|
|
432
|
+
audit_turn(grant, prompt, status: "refused", reason: "worker_timeout", images: images, revises: revises)
|
|
331
433
|
error_response(504, "worker_timeout", result.detail)
|
|
332
434
|
else error_response(500, "enqueue_failed", "could not write turn input")
|
|
333
435
|
end
|
|
@@ -416,17 +518,18 @@ module Samagotchi
|
|
|
416
518
|
# answer trimmed to what their turn's reply is (status, text, turn
|
|
417
519
|
# id, a cancel's reason): no file path, no question (an approval or a
|
|
418
520
|
# question their turn raised is the owner's to answer: the status
|
|
419
|
-
# says waiting_for_owner), no origin, no parent strategy. It waits
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
#
|
|
521
|
+
# says waiting_for_owner), no origin, no parent strategy. It waits on
|
|
522
|
+
# their turn's id, so it never falls back to the next reply past a
|
|
523
|
+
# cursor, which would be anyone's (the body's cursor doesn't count);
|
|
524
|
+
# a message merged into their own running turn is followed into it by
|
|
525
|
+
# its merge record (ReplyWait::Poll#follow_merge), a turn of theirs. A revoke or the expiry ends a wait
|
|
423
526
|
# already running (the gate checked the grant when it began): 401
|
|
424
527
|
# token_revoked / token_expired, as their next request would get.
|
|
425
528
|
def participant_wait(req, id, options)
|
|
426
529
|
grant = principal(req).grant
|
|
427
530
|
return not_permitted_for(req, principal(req)) unless own_turn?(grant, id, options[:turn])
|
|
428
531
|
|
|
429
|
-
options = options.merge(queued:
|
|
532
|
+
options = options.merge(queued: false, cursor: nil)
|
|
430
533
|
session = session_access.show(id)
|
|
431
534
|
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
432
535
|
cancelled: -> { !@server_running.call || !@grants.active?(grant.id) })
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../grants"
|
|
4
|
+
require_relative "../image_store"
|
|
5
|
+
require_relative "../participant_messages"
|
|
6
|
+
require_relative "../participant_uploads"
|
|
7
|
+
require_relative "../participants"
|
|
8
|
+
require_relative "../send_command"
|
|
9
|
+
require_relative "../session"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Web
|
|
13
|
+
# A participant's images, mixed into App: their uploads (POST
|
|
14
|
+
# /api/sessions/:id/images) and reading their own back (GET
|
|
15
|
+
# …/images/<hash>.<ext>). Ownership is ParticipantUploads' record alone,
|
|
16
|
+
# written here, under @participant_images_mutex. Their turn's refs are
|
|
17
|
+
# checked by ParticipantAccess#participant_images_refusal. See
|
|
18
|
+
# docs/internals/participants.md (Participant images).
|
|
19
|
+
module ParticipantImages
|
|
20
|
+
# One upload's answer: +ref+ when accepted, else +status+, +code+ and
|
|
21
|
+
# +detail+; +bytes+ the stored size (for the audit line).
|
|
22
|
+
Upload = Data.define(:status, :code, :detail, :ref, :bytes) do
|
|
23
|
+
def self.refused(status, code, detail) = new(status: status, code: code, detail: detail, ref: nil, bytes: nil)
|
|
24
|
+
def self.accepted(ref) = new(status: 201, code: nil, detail: nil, ref: ref, bytes: ref[:bytes])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The largest image a participant may upload, by its header: pixels
|
|
30
|
+
# and either side (a phone's 4032×3024 passes). Checked before any
|
|
31
|
+
# tool reads their bytes.
|
|
32
|
+
MAX_PARTICIPANT_PIXELS = 40_000_000
|
|
33
|
+
MAX_PARTICIPANT_SIDE = 12_000
|
|
34
|
+
|
|
35
|
+
def init_participant_images
|
|
36
|
+
@participant_images_mutex = Mutex.new
|
|
37
|
+
# One participant conversion (ImageStore.ingest, which may run sips
|
|
38
|
+
# or magick on their bytes) at a time, whoever's.
|
|
39
|
+
@participant_conversion_mutex = Mutex.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# POST /api/sessions/:id/images from a participant. Every upload
|
|
43
|
+
# counts as an attempt first (before the cap check). Refused, in
|
|
44
|
+
# order: their cap (429 cap_reached), their attempts (429
|
|
45
|
+
# too_many_image_attempts, refused ones counted), the size (413, as
|
|
46
|
+
# the owner's), not an image (422 bad_image), a bmp/tiff/heic (422
|
|
47
|
+
# unsupported_image: chi converts those with an external tool), no
|
|
48
|
+
# size in its header or over MAX_PARTICIPANT_PIXELS / _SIDE (422
|
|
49
|
+
# image_too_big), their quota (429 too_many_images; a file they own
|
|
50
|
+
# already is accepted again, no new entry). Every answer is audited,
|
|
51
|
+
# an exception too (internal_error, then raised). The reply is the ref
|
|
52
|
+
# without source, mime or bytes.
|
|
53
|
+
def participant_image_upload(req, id, session_dir)
|
|
54
|
+
grant = principal(req).grant
|
|
55
|
+
upload = begin
|
|
56
|
+
participant_upload(req, session_dir, grant)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
audit_image(id, grant, Upload.refused(500, "internal_error", nil))
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
audit_image(id, grant, upload)
|
|
62
|
+
return error_response(upload.status, upload.code, upload.detail) unless upload.ref
|
|
63
|
+
|
|
64
|
+
json_response(201, upload.ref.slice(:file, :name, :width, :height))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def audit_image(id, grant, upload)
|
|
68
|
+
Participants.log(id, :image, state_dir: default_state_dir, participant: grant.id,
|
|
69
|
+
status: upload.ref ? "accepted" : "refused",
|
|
70
|
+
**{ reason: upload.code, file: upload.ref&.dig(:file), bytes: upload.bytes }.compact)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def participant_upload(req, dir, grant)
|
|
74
|
+
over = @participant_images_mutex.synchronize do
|
|
75
|
+
record = ParticipantUploads.read(dir)
|
|
76
|
+
tried = record.attempts_of(grant.id)
|
|
77
|
+
ParticipantUploads.attempt!(dir, grant.id, state: record) if tried < Grants::MAX_IMAGE_ATTEMPTS
|
|
78
|
+
tried >= Grants::MAX_IMAGE_ATTEMPTS
|
|
79
|
+
end
|
|
80
|
+
if (cap = participant_cap_refusal(grant))
|
|
81
|
+
return Upload.refused(*cap)
|
|
82
|
+
end
|
|
83
|
+
if over
|
|
84
|
+
return Upload.refused(429, "too_many_image_attempts", ParticipantMessages::TOO_MANY_IMAGE_ATTEMPTS)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
bytes = image_upload_bytes(req)
|
|
88
|
+
return Upload.refused(413, "too_large", image_too_large_detail) unless bytes
|
|
89
|
+
|
|
90
|
+
participant_store(dir, grant, bytes, upload_name(req))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def participant_store(dir, grant, bytes, name)
|
|
94
|
+
info = ImageHeader.read(bytes)
|
|
95
|
+
return Upload.refused(422, "bad_image", ParticipantMessages::BAD_IMAGE) unless info
|
|
96
|
+
if ImageHeader::CONVERTIBLE.include?(info.format)
|
|
97
|
+
return Upload.refused(422, "unsupported_image", ParticipantMessages::IMAGE_TYPE)
|
|
98
|
+
end
|
|
99
|
+
return Upload.refused(422, "image_too_big", ParticipantMessages::IMAGE_TOO_BIG) unless participant_size_ok?(info)
|
|
100
|
+
|
|
101
|
+
# At quota: only a file they own already, named as its bytes would
|
|
102
|
+
# be stored unchanged (a downscaled or re-encoded one is refused).
|
|
103
|
+
raw = ImageStore.file_name_for(bytes, info.format)
|
|
104
|
+
again = @participant_images_mutex.synchronize do
|
|
105
|
+
record = ParticipantUploads.read(dir)
|
|
106
|
+
next nil if record.count(grant.id) < Grants::MAX_IMAGES
|
|
107
|
+
|
|
108
|
+
record.owned?(raw, grant.id)
|
|
109
|
+
end
|
|
110
|
+
return Upload.accepted(ImageStore.ref_for(dir, file: raw, name: name)) if again
|
|
111
|
+
return too_many_images if again == false
|
|
112
|
+
|
|
113
|
+
participant_ingest(dir, grant, bytes, name)
|
|
114
|
+
rescue ImageStore::Error
|
|
115
|
+
Upload.refused(422, "bad_image", ParticipantMessages::BAD_IMAGE)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether +info+'s header gives a size within the participant limits.
|
|
119
|
+
def participant_size_ok?(info)
|
|
120
|
+
return false unless info.width.is_a?(Integer) && info.height.is_a?(Integer) && info.width.positive? && info.height.positive?
|
|
121
|
+
|
|
122
|
+
[info.width, info.height].max <= MAX_PARTICIPANT_SIDE && info.width * info.height <= MAX_PARTICIPANT_PIXELS
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Stores their image, then records it: the quota checked again under
|
|
126
|
+
# the mutex (a race past it is refused here; the stored file may stay
|
|
127
|
+
# in images/, unreachable to them without a record).
|
|
128
|
+
def participant_ingest(dir, grant, bytes, name)
|
|
129
|
+
ref = @participant_conversion_mutex.synchronize { ImageStore.ingest(dir, bytes: bytes, name: name, source: "user") }
|
|
130
|
+
added = @participant_images_mutex.synchronize do
|
|
131
|
+
record = ParticipantUploads.read(dir)
|
|
132
|
+
next true if record.owned?(ref[:file], grant.id)
|
|
133
|
+
next false if record.count(grant.id) >= Grants::MAX_IMAGES
|
|
134
|
+
|
|
135
|
+
ParticipantUploads.add(dir, file: ref[:file], grant_id: grant.id, state: record)
|
|
136
|
+
end
|
|
137
|
+
added ? Upload.accepted(ref) : too_many_images
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A participant's turn's images: [nil, refs] to deliver (refs [] for
|
|
141
|
+
# none), or [[status, code, detail(, audit detail)], nil]. In this
|
|
142
|
+
# order, so nothing tells them which files exist: the shape (400
|
|
143
|
+
# bad_images), then ownership (403 not_permitted for any ref they
|
|
144
|
+
# didn't upload, there or not), only then the files (ImageStore.
|
|
145
|
+
# check_refs), then the model (422 no_vision in their words; the
|
|
146
|
+
# model's name and the /model hint only in the audit line).
|
|
147
|
+
def participant_images_refusal(grant, id, raw)
|
|
148
|
+
return [nil, []] if raw.nil? || raw == []
|
|
149
|
+
return [[400, "bad_images", ParticipantMessages::IMAGES_SHAPE], nil] unless participant_refs_shape?(raw)
|
|
150
|
+
|
|
151
|
+
dir = Session.session_dir(id, state_dir: default_state_dir)
|
|
152
|
+
record = @participant_images_mutex.synchronize { ParticipantUploads.read(dir) }
|
|
153
|
+
owned = raw.all? { |ref| record.owned?(ref["file"].to_s, grant.id) }
|
|
154
|
+
return [[403, "not_permitted", ParticipantMessages::NOT_PERMITTED], nil] unless owned
|
|
155
|
+
|
|
156
|
+
images = ImageStore.check_refs(dir, raw)
|
|
157
|
+
return [[400, "bad_images", ParticipantMessages::IMAGE_GONE, images], nil] if images.is_a?(String)
|
|
158
|
+
|
|
159
|
+
refusal = participant_vision_refusal(id)
|
|
160
|
+
refusal ? [[422, "no_vision", ParticipantMessages::NO_VISION, refusal], nil] : [nil, images]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def participant_refs_shape?(raw)
|
|
164
|
+
raw.is_a?(Array) && raw.size <= ImageStore::MAX_TURN_REFS &&
|
|
165
|
+
raw.all? { |ref| ref.is_a?(Hash) && ref["file"].is_a?(String) && !ref.key?("path") }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# chi send's refusal for this session's model, nil when it takes
|
|
169
|
+
# images or nobody knows (the Engine stays the backstop). The model's
|
|
170
|
+
# name can lag a live /model.
|
|
171
|
+
def participant_vision_refusal(id)
|
|
172
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
173
|
+
SendCommand.vision_refusal_for(session.model_name, typed: session.model_typed)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether the request's participant uploaded <session_dir>/images/+name+
|
|
177
|
+
# (a bare "<hash>.<ext>"): the record alone (they had the bytes).
|
|
178
|
+
def participant_owns_image?(req, session_dir, name)
|
|
179
|
+
ParticipantUploads.owned?(session_dir, "#{ImageStore::DIR}/#{name}", principal(req).grant.id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def too_many_images = Upload.refused(429, "too_many_images", ParticipantMessages::TOO_MANY_IMAGES)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|