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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -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, :owner, :session],
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, :owner],
315
- ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :owner],
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, :owner, :session],
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 (and
1683
- # the owner's ?as=participant, the participant view) goes through
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 the
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 a page's <body>, the
1996
- # owner's and the participant page's. Always there: an empty one means
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: a review turn (D6,
260
- # D10). Of the body only the prompt counts: the author and client id
261
- # are the grant's (review:<id>), the delivery is always queue, no
262
- # images, no no_interrupt; the owner's prompt history never sees it.
263
- # Refused: a line starting ! or / (403 not_permitted: shell lines
264
- # and commands are the owner's), images (403), their turn limit (429
265
- # too_many_turns, the Bridge counts) and their cap (429 cap_reached).
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
- reason = participant_turn_refusal(grant, prompt, body)
273
- return participant_turn_refused(grant, prompt, *reason) if reason
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; never a Revise (the owner's stacks).
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: Delivery::QUEUE, author: grant.author,
278
- anchors: TurnAnchors.from(body).without_revises,
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] unless body["images"].nil? || body["images"] == []
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
- def participant_turn_refused(grant, prompt, status, code, detail)
315
- audit_turn(grant, prompt, status: "refused", reason: code)
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
- def participant_turn_result(grant, prompt, result)
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
- json_response(202, result.ack.merge("via" => result.via.to_s, "delivery" => Delivery::QUEUE))
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 as
420
- # for a queued turn (theirs always is: never merged), so it never
421
- # falls back to the next reply past a cursor, which would be anyone's;
422
- # the body's cursor doesn't count. A revoke or the expiry ends a wait
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: true, cursor: nil)
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