samagotchi 0.121.0 → 0.122.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. metadata +6 -1
@@ -93,8 +93,9 @@ module Samagotchi
93
93
  SessionInbox.prune_old(session_dir, SETTLED_DIR, max_age: max_age)
94
94
  end
95
95
 
96
- def self.settled_record(id, ended_at: nil, seconds: nil)
97
- LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
96
+ def self.settled_record(id, ended_at: nil, seconds: nil, author: nil)
97
+ LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id,
98
+ author: author)
98
99
  end
99
100
  private_class_method :settled_record
100
101
 
@@ -123,13 +124,15 @@ module Samagotchi
123
124
 
124
125
  # A participant's queued turn the worker refused (Worker#refuse_participant_turn:
125
126
  # +reason+ revoked or cap_reached) ends its sender's wait: no turn ran,
126
- # canceled for that reason (ParentReport reads it as refused).
127
- def self.record_refused(session_dir, enqueued_id, reason:)
127
+ # canceled for that reason (ParentReport reads it as refused). Its
128
+ # +author+ (the participant's "p:<id>") says whose wait it is to a chi
129
+ # web restarted since (ParticipantAccess#turn_author).
130
+ def self.record_refused(session_dir, enqueued_id, reason:, author: nil)
128
131
  id = enqueued_id.to_s
129
132
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
130
133
 
131
134
  write_settled(session_dir, id, LastTurn.new(outcome: "canceled", cancel_reason: reason.to_s,
132
- ended_at: Time.now.iso8601(3), enqueued_id: id))
135
+ ended_at: Time.now.iso8601(3), enqueued_id: id, author: author))
133
136
  rescue SystemCallError, IOError => e
134
137
  Log.warn(:worker, "refused_turn_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
135
138
  end
@@ -138,7 +141,7 @@ module Samagotchi
138
141
  id = mark.enqueued_id.to_s
139
142
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
140
143
 
141
- write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
144
+ write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds, author: mark.author))
142
145
  rescue SystemCallError => e
143
146
  # The settle goes on; a wait for that turn runs to its timeout.
144
147
  Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
@@ -163,6 +166,7 @@ module Samagotchi
163
166
  @written_at = nil
164
167
  @started_at = nil
165
168
  @enqueued_id = nil
169
+ @author = nil
166
170
  end
167
171
 
168
172
  # A turn begins (before Engine#run_turn and the worker's running save):
@@ -170,7 +174,8 @@ module Samagotchi
170
174
  # images, no turn id), marked at iteration 0, so a worker killed before
171
175
  # the loop's first boundary leaves the prompt and a mark the next one
172
176
  # settles. A continue turn (no prompt) is the history, marked.
173
- # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
177
+ # Every mark of the turn carries +enqueued_id+ and a participant's
178
+ # +author+ (InterruptedTurn's).
174
179
  # @param prompt [String, nil]
175
180
  # @param enqueued_id [String, nil] the input the turn runs
176
181
  # @param author [Hash] a participant's prompt's author fields
@@ -179,6 +184,7 @@ module Samagotchi
179
184
  def begin_turn(prompt: nil, enqueued_id: nil, author: {})
180
185
  @boundaries = 0
181
186
  @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
187
+ @author = author[:author]
182
188
  @written_at = nil
183
189
  @started_at = Time.now.iso8601(3)
184
190
  messages = Array(@session.messages).dup
@@ -186,7 +192,7 @@ module Samagotchi
186
192
  # stamp (Engine's Turn#wall), which the turn's first boundary saves.
187
193
  messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
188
194
  @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
189
- enqueued_id: @enqueued_id) }
195
+ enqueued_id: @enqueued_id, author: @author) }
190
196
  end
191
197
 
192
198
  # The turn ended (or never began): the session saves its own messages.
@@ -212,7 +218,7 @@ module Samagotchi
212
218
  # forget stub) while a save on another thread serializes this one.
213
219
  messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
214
220
  marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
215
- enqueued_id: @enqueued_id)
221
+ enqueued_id: @enqueued_id, author: @author)
216
222
  @session.turn_checkpoint = { messages: messages, marker: marker }
217
223
  return false unless due? || !merged.empty?
218
224
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.121.0"
4
+ VERSION = "0.122.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -16,6 +16,7 @@ require_relative "../answer_tail"
16
16
  require_relative "../answer_refs"
17
17
  require_relative "../question_asked"
18
18
  require_relative "../bridge_client"
19
+ require_relative "../worker_link"
19
20
  require_relative "../bridge/bounded_queue"
20
21
  require_relative "../bridge/card_store"
21
22
  require_relative "../session"
@@ -54,6 +55,7 @@ require_relative "markdown_renderer"
54
55
  require_relative "capabilities"
55
56
  require_relative "editor"
56
57
  require_relative "../message_parts"
58
+ require_relative "../launch_mode"
57
59
  require_relative "session_summary"
58
60
  require_relative "session_access_routes"
59
61
  require_relative "principal"
@@ -321,6 +323,11 @@ module Samagotchi
321
323
  # Anything else is a file under public_dir (/assets/*, /public/* or
322
324
  # a bare path such as /style.css), or 404.
323
325
  serve_static(req)
326
+ rescue Toolkit::Refused => e
327
+ # A new session (create, continue, fork, review) on a config kit
328
+ # with an invalid groups: (Toolkit.check_new!), whichever handler
329
+ # spawned it.
330
+ error_response(400, "toolkit_refused", e.message)
324
331
  rescue StandardError => e
325
332
  Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
326
333
  # A participant never reads an exception's words (they may name a
@@ -1086,7 +1093,7 @@ module Samagotchi
1086
1093
  reply = request.call(client)
1087
1094
  # Not the route's own 403 (a parent's option refused): the worker
1088
1095
  # asked for a secret this chi web didn't send.
1089
- return error_response(502, BridgeSecret::ERROR, BridgeSecret::REFUSED) if reply.secret_refused?
1096
+ return error_response(502, BridgeSecret::ERROR, BridgeSecret.refused(id)) if reply.secret_refused?
1090
1097
 
1091
1098
  mapped = yield(reply)
1092
1099
  return mapped if mapped
@@ -1106,7 +1113,7 @@ module Samagotchi
1106
1113
  end
1107
1114
 
1108
1115
  def not_live(id)
1109
- error_response(503, "not_live", "no live bridge for session #{id}")
1116
+ error_response(503, "not_live", WorkerLink.not_live(id))
1110
1117
  end
1111
1118
 
1112
1119
  # A live worker refused or failed the request (a 500 in its handler, a
@@ -1134,8 +1141,9 @@ module Samagotchi
1134
1141
  # The session's Bridge, waiting briefly for one a resume just spawned.
1135
1142
  # @return [BridgeClient, nil]
1136
1143
  def live_bridge_client(id)
1137
- sidecar = bridge_sidecar(id) || await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
1138
- bridge_client_for(id, sidecar)
1144
+ link = worker_link(id)
1145
+ link = await_worker_link(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min) unless link.live?
1146
+ link_client(id, link)
1139
1147
  end
1140
1148
 
1141
1149
  # The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
@@ -1217,7 +1225,7 @@ module Samagotchi
1217
1225
  # (?full=1: as the list's).
1218
1226
  def handle_context_show(req, id, name)
1219
1227
  session = @session_class.load(id, state_dir: default_state_dir)
1220
- attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1228
+ attached = find_context(session, name) or return error_response(404, "context_missing", "no source #{name[0, 40]}")
1221
1229
  own = ContextSources.session_location(id, state_dir: default_state_dir)
1222
1230
  row = context_row(attached, own, own.subscriptions, full: truthy_param?(req.params["full"]))
1223
1231
  json_response(200, row.merge(text: attached.snapshot.text))
@@ -1297,7 +1305,7 @@ module Samagotchi
1297
1305
  return context_remove_own(id, name) if truthy_param?(req.params["own"])
1298
1306
 
1299
1307
  session = @session_class.load(id, state_dir: default_state_dir)
1300
- attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1308
+ attached = find_context(session, name) or return error_response(404, "context_missing", "no source #{name[0, 40]}")
1301
1309
  if attached.location.session?
1302
1310
  attached.location.remove(attached.name)
1303
1311
  json_response(200, { status: "detached", name: attached.name })
@@ -1312,10 +1320,10 @@ module Samagotchi
1312
1320
  when :removed then json_response(200, { status: "detached", name: name })
1313
1321
  when :project
1314
1322
  error_response(409, "project_source", ContextCommand.project_source_message(name))
1315
- else error_response(404, "not_found", "no source #{name[0, 40]}")
1323
+ else error_response(404, "context_missing", "no source #{name[0, 40]}")
1316
1324
  end
1317
1325
  rescue ContextSources::Invalid
1318
- error_response(404, "not_found", "no source #{name[0, 40]}")
1326
+ error_response(404, "context_missing", "no source #{name[0, 40]}")
1319
1327
  end
1320
1328
 
1321
1329
  def attached_context(session, shadowed: false)
@@ -1355,47 +1363,55 @@ module Samagotchi
1355
1363
  return error_response(404, "not_found", "Session not found: #{id}")
1356
1364
  end
1357
1365
 
1358
- # Single live transport: proxy to the session's Bridge. When no live
1359
- # worker exists there is nothing to stream — history is available via
1360
- # GET /api/sessions/:id/output.
1361
- sidecar = bridge_sidecar(id)
1362
- # A resume just spawned the worker: its bridge may still be binding.
1363
- # Wait briefly for it so the first SSE connect lands on a live bridge
1364
- # instead of an instantly-closed empty 200 / 503 during startup.
1365
- if sidecar.nil? && @bridge_wait_timeout.to_i.positive?
1366
- sidecar = await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
1366
+ link = worker_link(id)
1367
+ if link.is_a?(WorkerLink::Starting)
1368
+ return error_response(503, "not_live", format(LaunchMode::BRIDGE_LATE, id)) if link.stuck
1369
+
1370
+ link = await_worker_link(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
1367
1371
  end
1368
- return error_response(503, "not_live", "no live bridge for session #{id}") unless sidecar
1372
+ return stream_link_error(id, link) unless link.live?
1369
1373
 
1370
- client = bridge_client_for(id, sidecar)
1374
+ client = link_client(id, link)
1375
+ filter = stream_filter(req, id)
1376
+ query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
1377
+ opened = client.open_stream(query: query, last_event_id: req.env["HTTP_LAST_EVENT_ID"])
1378
+ return stream_open_error(id, opened) unless opened.is_a?(BridgeClient::StreamOpen::Opened)
1371
1379
 
1372
- headers = {
1373
- "Content-Type" => "text/event-stream",
1374
- "Cache-Control" => "no-cache",
1375
- "Connection" => "keep-alive",
1376
- "X-Accel-Buffering" => "no"
1377
- }
1380
+ handed_off = false
1381
+ begin
1382
+ headers = {
1383
+ "Content-Type" => "text/event-stream",
1384
+ "Cache-Control" => "no-cache",
1385
+ "Connection" => "keep-alive",
1386
+ "X-Accel-Buffering" => "no"
1387
+ }
1388
+
1389
+ # Handlers that buffer enumerable bodies before responding (rackup's
1390
+ # WEBrick does `body.each` to completion) hang forever on an unbounded
1391
+ # SSE body — the browser never even receives the status line. When the
1392
+ # handler supports hijacking, stream frames straight to the socket
1393
+ # instead of returning an enumerable.
1394
+ if req.env["rack.hijack?"]
1395
+ hijack = sse_hijack(id, req, client, opened, filter)
1396
+ handed_off = true
1397
+ return [200, headers.merge("rack.hijack" => hijack), []]
1398
+ end
1378
1399
 
1379
- # Handlers that buffer enumerable bodies before responding (rackup's
1380
- # WEBrick does `body.each` to completion) hang forever on an unbounded
1381
- # SSE body — the browser never even receives the status line. When the
1382
- # handler supports hijacking, stream frames straight to the socket
1383
- # instead of returning an enumerable.
1384
- if req.env["rack.hijack?"]
1385
- hijack = sse_hijack(id, req, client)
1386
- return [200, headers.merge("rack.hijack" => hijack), []]
1400
+ body = proxy_stream_body(id, req, client, opened, filter)
1401
+ handed_off = true
1402
+ [200, headers, body]
1403
+ ensure
1404
+ opened.sock.close unless handed_off
1387
1405
  end
1388
-
1389
- [200, headers, proxy_stream_body(id, req, client)]
1390
1406
  end
1391
1407
 
1392
1408
  # The one ProxyStreamBody both of #handle_stream's transports send:
1393
1409
  # the session's Bridge stream, through the request principal's frame
1394
1410
  # filter (#stream_filter).
1395
- def proxy_stream_body(id, req, client)
1411
+ def proxy_stream_body(_id, req, client, opened, filter)
1396
1412
  query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
1397
- ProxyStreamBody.new(client: client, query: query, headers: req.env,
1398
- server_running: stream_running(principal(req)), filter: stream_filter(req, id))
1413
+ ProxyStreamBody.new(client: client, opened: opened, query: query, headers: req.env,
1414
+ server_running: stream_running(principal(req)), filter: filter)
1399
1415
  end
1400
1416
 
1401
1417
  # What a request's stream goes through on its way from the Bridge:
@@ -1465,10 +1481,10 @@ module Samagotchi
1465
1481
  # frames directly to the client socket until it disconnects. The handler
1466
1482
  # (WEBrick via rackup) has already sent the status line + headers, so
1467
1483
  # only body bytes go to the socket here.
1468
- def sse_hijack(id, req, client)
1484
+ def sse_hijack(id, req, client, opened, filter)
1469
1485
  # Made before the status line goes out: a principal with no filter
1470
1486
  # is refused (#stream_filter), never streamed.
1471
- body = proxy_stream_body(id, req, client)
1487
+ body = proxy_stream_body(id, req, client, opened, filter)
1472
1488
  lambda do |io|
1473
1489
  body.each { |chunk| io.write(chunk) }
1474
1490
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
@@ -1476,36 +1492,67 @@ module Samagotchi
1476
1492
  rescue StandardError => e
1477
1493
  Log.warn(:web, "proxy_failed", sid: id, port: client.port, error: e.class.name, msg: e.message)
1478
1494
  nil
1495
+ ensure
1496
+ body.close
1479
1497
  end
1480
1498
  end
1481
1499
 
1482
- # Bounded wait for a freshly-spawned worker's bridge sidecar so the
1483
- # response hands the client a live transport without client-side polling.
1484
- # Returns the bridge's sidecar (WorkerSidecar), or nil when it is not up by the deadline (the
1485
- # session still exists; EventSource auto-reconnect covers late binders).
1486
- def await_bridge_sidecar(session_id, timeout: @bridge_wait_timeout)
1487
- return nil if timeout.nil? || timeout <= 0
1500
+ # Bounded wait for a freshly-spawned worker's Bridge so the response
1501
+ # hands the client a live transport without client-side polling (the
1502
+ # web's WorkerLink.await: #worker_link polled). Returns the first Live,
1503
+ # else the last outcome (None when +timeout+ allows no wait): the
1504
+ # session still exists; EventSource auto-reconnect covers late binders.
1505
+ # @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
1506
+ def await_worker_link(session_id, timeout: @bridge_wait_timeout)
1507
+ return WorkerLink::None.new if timeout.nil? || timeout <= 0
1508
+
1509
+ WorkerLink.await(session_id, session_dir: @session_class.session_dir(session_id, state_dir: default_state_dir),
1510
+ timeout: timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL,
1511
+ give_up: lambda do |outcome|
1512
+ outcome.is_a?(WorkerLink::Leaving) || outcome.is_a?(WorkerLink::Stale) ||
1513
+ outcome.is_a?(WorkerLink::Skew) ||
1514
+ (outcome.is_a?(WorkerLink::Starting) && outcome.stuck)
1515
+ end,
1516
+ host: DEFAULT_HOST)
1517
+ end
1518
+
1519
+ def stream_link_error(id, link)
1520
+ return error_response(502, "worker_newer", link.message(id)) if link.is_a?(WorkerLink::Skew)
1521
+ if link.is_a?(WorkerLink::Starting) && link.stuck
1522
+ return error_response(503, "not_live", format(LaunchMode::BRIDGE_LATE, id))
1523
+ end
1524
+ if link.is_a?(WorkerLink::Leaving)
1525
+ return json_response(503, { error: "not_live", detail: WorkerLink.not_live(id),
1526
+ leaving: true, leave_seconds: link.left.round(1) })
1527
+ end
1488
1528
 
1489
- BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar(session_id) }
1490
- rescue StandardError
1491
- nil
1529
+ error_response(503, "not_live", WorkerLink.not_live(id))
1530
+ end
1531
+
1532
+ def stream_open_error(id, outcome)
1533
+ return error_response(503, "not_live", WorkerLink.unanswered(id)) if outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
1534
+ return error_response(503, "not_live", WorkerLink.not_live(id)) if outcome.reason == :unknown_session
1535
+ return error_response(502, "bridge_secret", BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
1536
+
1537
+ error_response(502, "bridge_error", outcome.detail || "the session's worker answered #{outcome.status}")
1492
1538
  end
1493
1539
 
1494
- # The session's live Bridge's sidecar (stale ones are removed), or nil:
1495
- # read again for every request, so a respawned worker's port and secret
1496
- # are the ones used.
1497
- # @return [WorkerSidecar, nil]
1498
- def bridge_sidecar(session_id)
1540
+ # The session's worker link (WorkerLink.resolve; stale sidecars are
1541
+ # removed): read again for every request, so a respawned worker's port
1542
+ # and secret are the ones used.
1543
+ # @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
1544
+ def worker_link(session_id)
1499
1545
  dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
1500
- BridgeClient.live_sidecar(dir, host: DEFAULT_HOST)
1546
+ WorkerLink.resolve(dir, unlink: true, host: DEFAULT_HOST)
1501
1547
  rescue StandardError
1502
- nil
1548
+ WorkerLink::None.new
1503
1549
  end
1504
1550
 
1505
1551
  # @return [BridgeClient, nil] a client for the session's live Bridge
1506
- def bridge_client(session_id) = bridge_client_for(session_id, bridge_sidecar(session_id))
1552
+ def bridge_client(session_id) = link_client(session_id, worker_link(session_id))
1507
1553
 
1508
- def bridge_client_for(session_id, sidecar) = BridgeClient.for_sidecar(session_id, sidecar, host: DEFAULT_HOST)
1554
+ # @return [BridgeClient, nil] +link+'s client when it is Live
1555
+ def link_client(session_id, link) = link.live? ? link.client(session_id, host: DEFAULT_HOST) : nil
1509
1556
 
1510
1557
  # GET a read path of the session's live Bridge (+path+: "state",
1511
1558
  # "snapshot", "stats", ...).
@@ -1533,7 +1580,8 @@ module Samagotchi
1533
1580
 
1534
1581
  path = File.join(@public_dir, "index.html")
1535
1582
  if File.file?(path)
1536
- body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
1583
+ # A block: a preset's \& or \' is text, never a backreference.
1584
+ body = File.read(path).sub("<body>") { "<body #{index_data_attributes(req)}>" }
1537
1585
  [200, {
1538
1586
  "Content-Type" => "text/html; charset=utf-8",
1539
1587
  "Content-Length" => body.bytesize.to_s,
@@ -1823,9 +1871,12 @@ module Samagotchi
1823
1871
  # is kept (the env carries the token cookie, never held or inspected)
1824
1872
  # @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
1825
1873
  # it sees the Bridge's reads as they come, not whole frames
1826
- # @param client [BridgeClient] the session's Bridge (App#bridge_client_for)
1827
- def initialize(client:, query:, headers:, server_running: -> { true }, filter: nil)
1874
+ # @param client [BridgeClient] the session's Bridge (App#link_client)
1875
+ # @param opened [BridgeClient::StreamOpen::Opened] the socket whose
1876
+ # 200 was answered to the caller
1877
+ def initialize(client:, opened:, query:, headers:, server_running: -> { true }, filter: nil)
1828
1878
  @client = client
1879
+ @opened = opened
1829
1880
  @query = query
1830
1881
  @last_event_id = headers["HTTP_LAST_EVENT_ID"]
1831
1882
  @server_running = server_running
@@ -1833,16 +1884,23 @@ module Samagotchi
1833
1884
  end
1834
1885
 
1835
1886
  def each(&)
1836
- # Forward the browser's auto-reconnect cursor: the bridge prefers the
1837
- # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
1838
- # stale initial cursor — without this the bridge would replay content
1839
- # already delivered (duplicate bubbles).
1840
- return @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &) unless @filter
1841
-
1842
- @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running) do |chunk|
1843
- out = @filter.call(chunk)
1887
+ opened = @opened
1888
+ return unless opened
1889
+
1890
+ @client.read_stream(opened, running: @server_running) do |chunk|
1891
+ out = @filter ? @filter.call(chunk) : chunk
1844
1892
  yield(out) unless out.nil? || out.empty?
1845
1893
  end
1894
+ ensure
1895
+ @opened = nil
1896
+ end
1897
+
1898
+ def close
1899
+ opened = @opened
1900
+ @opened = nil
1901
+ opened&.sock&.close
1902
+ rescue IOError, SystemCallError
1903
+ nil
1846
1904
  end
1847
1905
  end
1848
1906
  end
@@ -13,6 +13,7 @@ require_relative "../parent_report"
13
13
  require_relative "../participant_messages"
14
14
  require_relative "../participants"
15
15
  require_relative "../session"
16
+ require_relative "../turn_checkpoint"
16
17
  require_relative "../log"
17
18
 
18
19
  module Samagotchi
@@ -116,7 +117,7 @@ module Samagotchi
116
117
  detail = ParticipantMessages::TOKEN_BOUND
117
118
  return error_response(401, "token_bound", detail) if req.path_info.start_with?("/api/")
118
119
 
119
- html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE, "#{detail[0].upcase}#{detail[1..]}."))
120
+ html_response(401, BOUND_PAGE.sub(ENDED_SENTENCE) { "#{detail[0].upcase}#{detail[1..]}." })
120
121
  end
121
122
 
122
123
  def settle_grant(grant)
@@ -144,7 +145,7 @@ module Samagotchi
144
145
  code, detail = ENDED.fetch(state)
145
146
  return error_response(401, code, detail) if req.path_info.start_with?("/api/")
146
147
 
147
- html_response(401, ENDED_PAGE.sub(ENDED_SENTENCE, "#{detail[0].upcase}#{detail[1..]}."))
148
+ html_response(401, ENDED_PAGE.sub(ENDED_SENTENCE) { "#{detail[0].upcase}#{detail[1..]}." })
148
149
  end
149
150
 
150
151
  def audit_expired(grant)
@@ -336,20 +337,38 @@ module Samagotchi
336
337
  end
337
338
 
338
339
  # Whether the turn +enqueued_id+ of session +id+ is +grant+'s: chi
339
- # web's record of who sent it, else (a chi web restarted since) its
340
- # waiting input file's author, which only a principal stamps.
340
+ # web's record of who sent it, else (a chi web restarted since) the
341
+ # author the session's files keep for it (#turn_author), which only a
342
+ # principal stamps.
341
343
  def own_turn?(grant, id, enqueued_id)
342
344
  return false unless enqueued_id.is_a?(String) && !enqueued_id.empty?
343
345
 
344
346
  sender = @participant_mutex.synchronize { @participant_turns[enqueued_id] }
345
347
  return sender == grant.id if sender
346
348
 
347
- SessionInbox.find_new_input_files(Session.session_dir(id, state_dir: default_state_dir)).any? do |file|
349
+ turn_author(id, enqueued_id) == grant.author
350
+ end
351
+
352
+ # Who queued the turn +enqueued_id+, as the session's files say: its
353
+ # input file's author while it waits or runs (the claimed
354
+ # *.json.processing stays until the turn ends), then the turn's end:
355
+ # last_turn's author, or its settled record's (a refused turn's).
356
+ # nil for the owner's turn, or one nothing names.
357
+ def turn_author(id, enqueued_id)
358
+ dir = Session.session_dir(id, state_dir: default_state_dir)
359
+ files = SessionInbox.find_new_input_files(dir) + SessionInbox.claimed_input_files(dir)
360
+ files.each do |file|
348
361
  data = JSON.parse(File.read(file))
349
- data.is_a?(Hash) && data["enqueued_id"] == enqueued_id && data["author"] == grant.author
362
+ return data["author"] if data.is_a?(Hash) && data["enqueued_id"] == enqueued_id
350
363
  rescue JSON::ParserError, SystemCallError
351
- false
364
+ next
352
365
  end
366
+ last = session_access.show(id).last_turn
367
+ return last.author if last&.enqueued_id == enqueued_id
368
+
369
+ TurnCheckpoint.settled_turn(dir, enqueued_id)&.author
370
+ rescue ArgumentError, JSON::ParserError, SystemCallError
371
+ nil
353
372
  end
354
373
 
355
374
  # GET /api/sessions/resolve?id= for a participant: their grant's
@@ -401,7 +420,10 @@ module Samagotchi
401
420
  result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
402
421
  cancelled: -> { !@server_running.call || !@grants.active?(grant.id) })
403
422
  if result.status == :canceled
404
- ended = @grants.find(grant.id)&.state(@grants.now)
423
+ current = @grants.find(grant.id)
424
+ return error_response(401, *ENDED.fetch(:revoked)) unless current
425
+
426
+ ended = current.state(@grants.now)
405
427
  return error_response(401, *ENDED.fetch(ended)) if ENDED.key?(ended)
406
428
 
407
429
  return error_response(503, "server_stopping", "chi web is stopping; wait again")
@@ -6,6 +6,7 @@ require_relative "participant_exposure"
6
6
  require_relative "../session_metrics"
7
7
  require_relative "../review_start"
8
8
  require_relative "../guardrails/review_paths"
9
+ require_relative "../participant_mode"
9
10
 
10
11
  module Samagotchi
11
12
  module Web
@@ -49,6 +50,7 @@ module Samagotchi
49
50
  # current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
50
51
  def handle_review(req, id)
51
52
  session = @session_class.load(id, state_dir: default_state_dir)
53
+ mode = ParticipantMode.of(session).to_s
52
54
  viewer = participant_viewer(req, session)
53
55
  live = bridge_get_json(id, "snapshot")
54
56
  turn_snapshot = live && live["snapshot"]
@@ -63,7 +65,7 @@ module Samagotchi
63
65
  status = displayed_status(session, state)
64
66
  status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
65
67
  json_response(200, {
66
- viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer),
68
+ viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
67
69
  session: { id: session.id, title: participant_title(session), status: status },
68
70
  subjects: review_subjects(session, viewer),
69
71
  participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
@@ -149,12 +151,12 @@ module Samagotchi
149
151
  # participant view (no composer, no usage: the owner has /stats).
150
152
  # +state+ the live worker's session state (nil without one), +queued+
151
153
  # its queue.
152
- def review_viewer(req, id, session:, state:, queued:, viewer:)
153
- return { kind: "owner", can_comment: false } if principal(req).owner?
154
+ def review_viewer(req, id, session:, state:, queued:, viewer:, mode:)
155
+ return { kind: "owner", can_comment: false, mode: mode } if principal(req).owner?
154
156
 
155
157
  grant = principal(req).grant
156
158
  capped = !participant_cap_refusal(grant).nil?
157
- { kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, can_comment: !capped,
159
+ { kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, mode: mode, can_comment: !capped,
158
160
  cap_reached: capped, session_id: id,
159
161
  usage: review_usage(grant, session: session, state: state, queued: queued, viewer: viewer) }
160
162
  end
@@ -248,7 +250,8 @@ module Samagotchi
248
250
  # The owner's participant view has no composer; a participant's
249
251
  # selection quotes with the same presets as the owner's page.
250
252
  attrs.merge!(annotate_presets_data) unless principal(req).owner?
251
- html_response(200, File.read(path).sub("<body>", "<body #{body_data(attrs)}>"))
253
+ # A block: a name's \& or \' is text, never a backreference.
254
+ html_response(200, File.read(path).sub("<body>") { "<body #{body_data(attrs)}>" })
252
255
  end
253
256
 
254
257
  # A filtered stream's frame filter (App#stream_filter), for the
@@ -374,14 +374,18 @@ module Samagotchi
374
374
  /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
375
375
  /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
376
376
  /\bchp_[A-Za-z0-9_-]{20,}/,
377
+ # A one-time link code (LinkCodes::CODE_RE): a bearer credential
378
+ # until its grant expires (an owner's, 10 minutes).
379
+ /\bchc_[A-Za-z0-9_-]{43}/,
377
380
  /\bAIza[0-9A-Za-z_-]{30,}/,
378
381
  /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/,
379
382
  /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
380
383
  %r{https://hooks\.slack\.com/services/[A-Za-z0-9/_-]+}
381
384
  ].freeze
382
- # The value after an Authorization header or a token=/key= pair.
385
+ # The value after an Authorization (or a Bridge's secret,
386
+ # BridgeSecret::HEADER) header or a token=/key= pair.
383
387
  VALUES = [
384
- /(\bAuthorization:\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/i,
388
+ /(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/i,
385
389
  /((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/i,
386
390
  # A URL's password: scheme://user:PASSWORD@host.
387
391
  %r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@\s/]+(?=@)}i
@@ -389,7 +393,7 @@ module Samagotchi
389
393
  # A text that ends where one of VALUES' values (or a private key's
390
394
  # BEGIN line) would go on: a stream cut there would send the value
391
395
  # on its own, unredacted (ParticipantStreamFilter never cuts there).
392
- OPEN_VALUE = /(?:\bAuthorization:\s*(?:(?:Bearer|Basic|Token)\s*)?|[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*(?:[=:]\s*["']?)?|-----BEGIN [A-Z0-9 ]*)\z/i
396
+ OPEN_VALUE = /(?:\b(?:Authorization|X-Chi-Bridge):\s*(?:(?:Bearer|Basic|Token)\s*)?|[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*(?:[=:]\s*["']?)?|-----BEGIN [A-Z0-9 ]*)\z/i
393
397
 
394
398
  module_function
395
399
 
@@ -33,8 +33,15 @@ module Samagotchi
33
33
  warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
34
34
  json_response(200, { participants: with_spend(id, grants), warning: warning,
35
35
  identity_header: @public_url&.identity_header })
36
+ rescue Grants::Unwritable => e
37
+ grants_unwritable(e)
36
38
  end
37
39
 
40
+ # 409 grants_unwritable for a store chi won't read or write over
41
+ # (Grants::Corrupt, Grants::Newer), in the store's own words: a
42
+ # refusal the panel shows, not a 500 with a backtrace per refresh.
43
+ def grants_unwritable(error) = error_response(409, "grants_unwritable", error.message)
44
+
38
45
  # One session's grants with what each spent and how many turns they
39
46
  # ran (its saved analytics: SessionMetrics.saved_by_author), for the
40
47
  # owner's participants panel.
@@ -69,6 +76,8 @@ module Samagotchi
69
76
  link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
70
77
  rescue Grants::Invalid => e
71
78
  error_response(400, "invalid_participant", e.message)
79
+ rescue Grants::Unwritable => e
80
+ grants_unwritable(e)
72
81
  end
73
82
 
74
83
  # GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
@@ -102,6 +111,8 @@ module Samagotchi
102
111
  revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
103
112
  now = @grants.now
104
113
  json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
114
+ rescue Grants::Unwritable => e
115
+ grants_unwritable(e)
105
116
  end
106
117
 
107
118
  # POST /api/sessions/:id/participants/:participant/cap {cap}: the
@@ -119,6 +130,8 @@ module Samagotchi
119
130
  json_response(200, { participant: grant.public_h(@grants.now) })
120
131
  rescue Grants::Invalid => e
121
132
  error_response(400, "invalid_participant", e.message)
133
+ rescue Grants::Unwritable => e
134
+ grants_unwritable(e)
122
135
  end
123
136
 
124
137
  # The link a participant opens: web.public_url's with a one-time code
@@ -2,14 +2,13 @@
2
2
 
3
3
  module Samagotchi
4
4
  module Web
5
- # Who a chi web request is (App#principal_for). Today there is one
6
- # kind: the owner, a peer on this machine or a request with the
7
- # Web::Token access token. The routes' permission column
5
+ # Who a chi web request is (App#principal_for): the owner or a
6
+ # participant admitted by a grant. The routes' permission column
8
7
  # (App::ROUTES, :owner or :participant) is checked against it, and a
9
8
  # turn's author comes from it, never from the request body. See
10
9
  # docs/internals/remote-sessions.md (Trust model).
11
10
  #
12
- # +kind+ :owner; +grant+ what a non-owner principal was admitted by
11
+ # +kind+ :owner or :participant; +grant+ what admitted a participant
13
12
  # (nil for the owner).
14
13
  Principal = Data.define(:kind, :grant) do
15
14
  def owner? = kind == :owner