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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +3 -1
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +6 -2
- data/docs/sessions.md +15 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/toolkit.rb +68 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +23 -9
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- 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
|
-
|
|
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+
|
|
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
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
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",
|
|
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
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
|
1372
|
+
return stream_link_error(id, link) unless link.live?
|
|
1369
1373
|
|
|
1370
|
-
client =
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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(
|
|
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:
|
|
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
|
|
1483
|
-
#
|
|
1484
|
-
#
|
|
1485
|
-
#
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
1495
|
-
# read again for every request, so a respawned worker's port
|
|
1496
|
-
# are the ones used.
|
|
1497
|
-
# @return [
|
|
1498
|
-
def
|
|
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
|
-
|
|
1546
|
+
WorkerLink.resolve(dir, unlink: true, host: DEFAULT_HOST)
|
|
1501
1547
|
rescue StandardError
|
|
1502
|
-
|
|
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) =
|
|
1552
|
+
def bridge_client(session_id) = link_client(session_id, worker_link(session_id))
|
|
1507
1553
|
|
|
1508
|
-
|
|
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
|
-
|
|
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#
|
|
1827
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
|
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
|
|
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)
|
|
340
|
-
#
|
|
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
|
-
|
|
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
|
|
362
|
+
return data["author"] if data.is_a?(Hash) && data["enqueued_id"] == enqueued_id
|
|
350
363
|
rescue JSON::ParserError, SystemCallError
|
|
351
|
-
|
|
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
|
-
|
|
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
|
|
385
|
+
# The value after an Authorization (or a Bridge's secret,
|
|
386
|
+
# BridgeSecret::HEADER) header or a token=/key= pair.
|
|
383
387
|
VALUES = [
|
|
384
|
-
/(\
|
|
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 = /(?:\
|
|
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
|
-
|
|
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">
|