samagotchi 0.121.0 → 0.121.1

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -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.121.1"
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.
@@ -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.not_live(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
@@ -248,7 +248,8 @@ module Samagotchi
248
248
  # The owner's participant view has no composer; a participant's
249
249
  # selection quotes with the same presets as the owner's page.
250
250
  attrs.merge!(annotate_presets_data) unless principal(req).owner?
251
- html_response(200, File.read(path).sub("<body>", "<body #{body_data(attrs)}>"))
251
+ # A block: a name's \& or \' is text, never a backreference.
252
+ html_response(200, File.read(path).sub("<body>") { "<body #{body_data(attrs)}>" })
252
253
  end
253
254
 
254
255
  # 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
@@ -173,7 +173,7 @@ const participantsControl = createParticipantsControl({
173
173
  // An older prompt drawn before the list came says "a participant": named now.
174
174
  onNames: () => nameUnnamedBadges(document),
175
175
  onChange: () => updateInfoBar(),
176
- toast: (text) => showToast(text),
176
+ toast: (text, action, options) => showToast(text, action, options),
177
177
  });
178
178
  // The start page's model picker and its LLM context and thinking chips
179
179
  // (start_page.js); a fresh chain link's picker too.
@@ -154,6 +154,11 @@ export function createNotifications({
154
154
  let syncAgain = false;
155
155
  // When this page last had the browser check for a newer sw.js.
156
156
  let swCheckedAt = null;
157
+ // startPush's run in flight, and whether the last one failed (GET
158
+ // /api/push while chi web restarts, a phone just woken): the page tries
159
+ // again when it comes back in front.
160
+ let pushStarting = null;
161
+ let pushFailed = false;
157
162
  const badgeKeys = new Set();
158
163
  let baseTitle = "Chi";
159
164
 
@@ -258,8 +263,10 @@ export function createNotifications({
258
263
 
259
264
  // The Home Screen app: its service worker, chi web's key, and whether
260
265
  // chi web has this device. A tab, or chi web without push: the bell as is.
266
+ // One that failed is run again by backInFront (retryPush).
261
267
  async function startPush() {
262
268
  if (typeof NotificationApi !== "function" || !pushSupported({ win, nav })) return;
269
+ pushFailed = false;
263
270
  try {
264
271
  const reg = await nav.serviceWorker.register("/sw.js", { scope: "/" });
265
272
  const sub = await reg.pushManager.getSubscription();
@@ -274,11 +281,20 @@ export function createNotifications({
274
281
  // register() has just checked for a newer sw.js.
275
282
  swCheckedAt = now();
276
283
  renderNotifyBtn();
277
- } catch (_) {}
284
+ } catch (_) {
285
+ pushFailed = true;
286
+ }
278
287
  watchAll();
279
288
  syncDevice();
280
289
  }
281
290
 
291
+ // startPush again, once at a time, while the last one failed and push
292
+ // isn't known yet (no timer: the next time the page comes back in front).
293
+ function retryPush() {
294
+ if (push || !pushFailed || pushStarting) return;
295
+ pushStarting = startPush().finally(() => { pushStarting = null; });
296
+ }
297
+
282
298
  // A Home Screen app can stay open for days: when it comes back in front
283
299
  // it asks for a newer sw.js, at most once per SW_UPDATE_MS.
284
300
  function updateWorker() {
@@ -417,6 +433,7 @@ export function createNotifications({
417
433
  function backInFront() {
418
434
  if (doc.visibilityState !== "visible") return;
419
435
  clearBadge();
436
+ retryPush();
420
437
  watchAll();
421
438
  syncDevice();
422
439
  updateWorker();
@@ -453,6 +470,7 @@ export function createNotifications({
453
470
  button.addEventListener("click", toggleNotify);
454
471
  renderNotifyBtn();
455
472
  const pushReady = startPush();
473
+ pushStarting = pushReady.finally(() => { pushStarting = null; });
456
474
 
457
475
  return {
458
476
  noticeSessions,
@@ -470,5 +488,7 @@ export function createNotifications({
470
488
  pushReady,
471
489
  // The device's tidy-up in flight, or null (specs await it).
472
490
  syncing: () => syncing,
491
+ // startPush in flight (the first, or a retry), or null (specs await it).
492
+ pushStarting: () => pushStarting,
473
493
  };
474
494
  }
@@ -67,7 +67,8 @@ body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica
67
67
  .p-person{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;border:1px solid var(--border)}
68
68
  .p-dot{width:7px;height:7px;border-radius:50%;background:var(--author-color,var(--author-owner))}
69
69
  .p-note{flex-basis:100%;font-size:12.5px;color:var(--text-muted)}
70
- .p-offline{flex-basis:100%;font-size:12.5px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:8px;padding:4px 10px}
70
+ /* Over the thread's top, out of the sticky header's flow: a read that fails (or recovers) doesn't move the thread. */
71
+ .p-offline{position:absolute;top:calc(100% + 6px);left:16px;right:16px;max-width:760px;margin:0 auto;box-sizing:border-box;box-shadow:var(--toast-shadow);font-size:12.5px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:8px;padding:4px 10px}
71
72
  .p-view-banner{flex-basis:100%;font-size:12.5px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:8px;padding:4px 10px}
72
73
  main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
73
74
  .p-thread{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
@@ -167,9 +168,9 @@ button.small{font-size:12px;padding:2px 8px}
167
168
  aria-controls="main" data-view="thread">Thread</button></div>
168
169
  <div class="p-people" id="people"></div>
169
170
  <div class="p-view-banner" id="viewBanner" hidden>Participant view: exactly what participants see of this session, through the same filter. Read-only.</div>
170
- <div class="p-offline" id="offline" role="status" hidden>Can't reach the session — retrying…</div>
171
171
  <div class="p-note" id="note"></div>
172
172
  </div>
173
+ <div class="p-offline" id="offline" role="status" hidden>Can't reach the session — retrying…</div>
173
174
  </header>
174
175
  <div class="p-body">
175
176
  <aside class="p-plan" id="plan" hidden aria-label="Under review">