samagotchi 0.107.0 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -1
- data/docs/architecture.md +3 -2
- data/docs/internals/engine.md +17 -6
- data/docs/internals/participants.md +5 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/memory.md +20 -0
- data/docs/plugins.md +9 -2
- data/docs/releasing.md +11 -6
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +1 -1
- data/docs/testing.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
- data/lib/samagotchi/bridge.rb +3 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
- data/lib/samagotchi/child_reports.rb +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/context_sources.rb +1 -6
- data/lib/samagotchi/continue_offer.rb +8 -6
- data/lib/samagotchi/engine.rb +36 -25
- data/lib/samagotchi/guardrail_wiring.rb +9 -6
- data/lib/samagotchi/idle_scheduler.rb +17 -2
- data/lib/samagotchi/last_turn.rb +7 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_orphans.rb +74 -0
- data/lib/samagotchi/memory_paths.rb +4 -4
- data/lib/samagotchi/parent_report.rb +11 -3
- data/lib/samagotchi/reply_wait.rb +7 -2
- data/lib/samagotchi/send_command/busy.rb +5 -2
- data/lib/samagotchi/send_command/wait_target.rb +16 -11
- data/lib/samagotchi/session_inbox.rb +12 -8
- data/lib/samagotchi/session_metrics.rb +7 -6
- data/lib/samagotchi/session_participants.rb +3 -1
- data/lib/samagotchi/steer.rb +11 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
- data/lib/samagotchi/tools/memory.rb +16 -9
- data/lib/samagotchi/turn_origin.rb +68 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -0
- data/lib/samagotchi/web/grants.rb +2 -1
- data/lib/samagotchi/web/participant_access.rb +6 -116
- data/lib/samagotchi/web/participant_view.rb +7 -13
- data/lib/samagotchi/web/participants_admin.rb +129 -0
- data/lib/samagotchi/web/public/participant.js +2 -2
- data/lib/samagotchi/web/public/participant_model.js +5 -0
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/participants_panel.js +2 -2
- data/lib/samagotchi/worker.rb +30 -25
- data/lib/samagotchi/worker_inbound.rb +12 -9
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +4 -1
|
@@ -4,15 +4,12 @@ require "json"
|
|
|
4
4
|
require_relative "grants"
|
|
5
5
|
require_relative "principal"
|
|
6
6
|
require_relative "participant_view"
|
|
7
|
-
require_relative "qr"
|
|
8
7
|
require_relative "../archive_store"
|
|
9
8
|
require_relative "../client_id"
|
|
10
9
|
require_relative "../delivery"
|
|
11
10
|
require_relative "../session_inbox"
|
|
12
11
|
require_relative "../session_metrics"
|
|
13
12
|
require_relative "../parent_report"
|
|
14
|
-
require_relative "../review_start"
|
|
15
|
-
require_relative "../review_apply"
|
|
16
13
|
require_relative "../participant_messages"
|
|
17
14
|
require_relative "../participants"
|
|
18
15
|
require_relative "../session"
|
|
@@ -22,10 +19,12 @@ module Samagotchi
|
|
|
22
19
|
module Web
|
|
23
20
|
# What a participant (a Web::Grants token) may do in chi web, mixed into
|
|
24
21
|
# App: the gate's lookup of their grant, the refusals that are theirs
|
|
25
|
-
# (token_revoked, token_expired, another session's
|
|
26
|
-
# by role), the trimmed answers of
|
|
27
|
-
#
|
|
28
|
-
#
|
|
22
|
+
# (token_revoked, token_expired with its ended page, another session's
|
|
23
|
+
# 404, not_permitted by role), the trimmed answers of their own routes
|
|
24
|
+
# (info, the session, resolve, a wait on their turn), their turns, and
|
|
25
|
+
# the running check that ends their streams when the grant does. What
|
|
26
|
+
# they read is ParticipantReview's; the owner's routes for them (invite,
|
|
27
|
+
# list, revoke, cap, Apply) are ParticipantsAdmin's. See
|
|
29
28
|
# docs/internals/participants.md.
|
|
30
29
|
module ParticipantAccess
|
|
31
30
|
# A participant's refused routes are audited at most this often per
|
|
@@ -376,115 +375,6 @@ module Samagotchi
|
|
|
376
375
|
-> { @server_running.call && @grants.active?(id) }
|
|
377
376
|
end
|
|
378
377
|
|
|
379
|
-
# ── The owner's routes ─────────────────────────────────────────────
|
|
380
|
-
|
|
381
|
-
# GET /api/sessions/:id/participants, GET /api/participants: the
|
|
382
|
-
# grants (one session's, or every one), in every state, never a token
|
|
383
|
-
# or digest. One session's also carries +warning+: the share warning
|
|
384
|
-
# for a session that isn't a review session (ReviewStart.share_warning,
|
|
385
|
-
# null for one), which the panel shows before its Invite.
|
|
386
|
-
def handle_participants_list(_req, id = nil)
|
|
387
|
-
now = @grants.now
|
|
388
|
-
grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
|
|
389
|
-
return json_response(200, { participants: grants }) unless id
|
|
390
|
-
|
|
391
|
-
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
392
|
-
json_response(200, { participants: with_spend(id, grants), warning: warning })
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
# One session's grants with what each spent and how many turns they
|
|
396
|
-
# ran (its saved analytics: SessionMetrics.saved_by_author), for the
|
|
397
|
-
# owner's participants panel.
|
|
398
|
-
def with_spend(id, grants)
|
|
399
|
-
rows = SessionMetrics.saved_by_author(Session.session_dir(id, state_dir: default_state_dir))
|
|
400
|
-
grants.map do |grant|
|
|
401
|
-
row = rows["p:#{grant[:id]}"] || { turns: 0, cost: 0.0 }
|
|
402
|
-
grant.merge(spend: row[:cost].round(4), turns: row[:turns])
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
# POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
|
|
407
|
-
# cap? (dollars)}: a new grant. The answer carries the token and the
|
|
408
|
-
# link once (chi keeps only its digest), and +warning+ for a session
|
|
409
|
-
# that isn't a review session (ReviewStart.share_warning; null for one).
|
|
410
|
-
def handle_participants_invite(req, id)
|
|
411
|
-
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
412
|
-
ttl = body["ttl"]
|
|
413
|
-
return error_response(400, "invalid_participant", "ttl is a number of seconds") unless ttl.nil? || ttl.is_a?(Integer)
|
|
414
|
-
|
|
415
|
-
cap = body["cap"]
|
|
416
|
-
return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
|
|
417
|
-
|
|
418
|
-
issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
|
|
419
|
-
ttl: ttl, cap: cap, state_dir: default_state_dir)
|
|
420
|
-
link = participant_link(req, id, issued.token)
|
|
421
|
-
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
422
|
-
json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
|
|
423
|
-
link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
424
|
-
rescue Grants::Invalid => e
|
|
425
|
-
error_response(400, "invalid_participant", e.message)
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
# GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
|
|
429
|
-
# applies review turn T's proposal (ReviewApply: the reply quoted
|
|
430
|
-
# under one line of the owner's), for the composer: never sent from
|
|
431
|
-
# here. Without turn, the latest review turn with a reply. Read from
|
|
432
|
-
# the live worker's messages, else the saved ones.
|
|
433
|
-
# @return 200 {text, turn_id, author, author_name}; 404 not_found (no
|
|
434
|
-
# such turn, or no review turn); 400 not_review_turn; 409 no_reply
|
|
435
|
-
def handle_apply(req, id)
|
|
436
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
437
|
-
live = bridge_get_json(id, "snapshot")
|
|
438
|
-
messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
|
|
439
|
-
proposal = ReviewApply.find(messages, req.params["turn"])
|
|
440
|
-
json_response(200, { text: ReviewApply.compose(proposal), turn_id: proposal.turn_id, author: proposal.author,
|
|
441
|
-
author_name: proposal.author_name })
|
|
442
|
-
rescue ReviewApply::Refused => e
|
|
443
|
-
status = { not_found: 404, not_review_turn: 400, no_reply: 409 }.fetch(e.code)
|
|
444
|
-
error_response(status, e.code.to_s, e.message)
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
# DELETE /api/sessions/:id/participants[?id=p_…], DELETE
|
|
448
|
-
# /api/participants: revokes one participant, a session's or every
|
|
449
|
-
# one's; their open streams end within a poll.
|
|
450
|
-
def handle_participants_revoke(req, id = nil)
|
|
451
|
-
grant_id = req.params["id"]
|
|
452
|
-
if grant_id && !Grants::ID_RE.match?(grant_id.to_s)
|
|
453
|
-
return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
|
|
457
|
-
now = @grants.now
|
|
458
|
-
json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# POST /api/sessions/:id/participants/:participant/cap {cap}: the
|
|
462
|
-
# active participant's new spending cap (dollars), from their next
|
|
463
|
-
# turn on. 200 {participant}; 400 invalid_participant.
|
|
464
|
-
def handle_participants_cap(req, id, grant_id)
|
|
465
|
-
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
466
|
-
unless Grants::ID_RE.match?(grant_id.to_s)
|
|
467
|
-
return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
grant = Participants.set_cap(@grants, session_id: id, id: grant_id, cap: body["cap"], state_dir: default_state_dir)
|
|
471
|
-
return error_response(400, "invalid_participant", Participants.no_participant(grant_id, id)) unless grant
|
|
472
|
-
|
|
473
|
-
json_response(200, { participant: grant.public_h(@grants.now) })
|
|
474
|
-
rescue Grants::Invalid => e
|
|
475
|
-
error_response(400, "invalid_participant", e.message)
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# The link a participant opens: this chi web's LAN address when it has
|
|
479
|
-
# one (the owner may be asking on loopback), else the Host asked; the
|
|
480
|
-
# token traded for the cookie, the session in the fragment.
|
|
481
|
-
def participant_link(req, id, token)
|
|
482
|
-
host = req.get_header("HTTP_HOST").to_s
|
|
483
|
-
port = host[/:(\d+)\z/, 1] || req.port.to_s
|
|
484
|
-
name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
|
|
485
|
-
"http://#{name}:#{port}/?token=#{token}#s=#{id}"
|
|
486
|
-
end
|
|
487
|
-
|
|
488
378
|
def html_response(status, body)
|
|
489
379
|
[status, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s,
|
|
490
380
|
"Cache-Control" => "no-store" }, [body]]
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../client_id"
|
|
4
3
|
require_relative "../output_formatter"
|
|
5
4
|
require_relative "../steer"
|
|
5
|
+
require_relative "../turn_origin"
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
module Web
|
|
@@ -102,17 +102,18 @@ module Samagotchi
|
|
|
102
102
|
|
|
103
103
|
# A turn's start: who wrote it, and its prompt when a person sent it
|
|
104
104
|
# (an automatic turn's prompt, a reminder's or a task's, is not
|
|
105
|
-
# shown
|
|
105
|
+
# shown: a person is the owner's web or TUI, chi send, a participant,
|
|
106
|
+
# or no origin, an in-process turn: the owner's; TurnOrigin#human?).
|
|
107
|
+
# +mine+ when the viewer queued it.
|
|
106
108
|
def turn_started(event, viewer)
|
|
107
|
-
origin = field(event, :origin)
|
|
108
|
-
|
|
109
|
-
author = field(event, :author) || field(origin, :author)
|
|
109
|
+
origin = TurnOrigin.of(field(event, :origin))
|
|
110
|
+
author = field(event, :author) || origin.author
|
|
110
111
|
out = { "type" => "turn_started", **id_fields(event) }
|
|
111
112
|
out.merge!(author_fields(author, field(event, :author_name), viewer))
|
|
112
113
|
prompt = field(event, :prompt)
|
|
113
114
|
if field(event, :continue)
|
|
114
115
|
out["continue"] = true
|
|
115
|
-
elsif
|
|
116
|
+
elsif origin.human?
|
|
116
117
|
out["prompt"] = prompt.to_s unless prompt.nil?
|
|
117
118
|
else
|
|
118
119
|
out["automatic"] = true
|
|
@@ -286,13 +287,6 @@ module Samagotchi
|
|
|
286
287
|
out
|
|
287
288
|
end
|
|
288
289
|
|
|
289
|
-
# Sent by a person (the owner's web or TUI, chi send, a participant),
|
|
290
|
-
# or with no origin (an in-process turn: the owner's).
|
|
291
|
-
def person_turn?(origin)
|
|
292
|
-
client_id = field(origin, :client_id)
|
|
293
|
-
client_id.nil? || ClientId.human?(client_id)
|
|
294
|
-
end
|
|
295
|
-
|
|
296
290
|
def id_fields(event)
|
|
297
291
|
out = seq(event)
|
|
298
292
|
turn_id = field(event, :turn_id)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "grants"
|
|
4
|
+
require_relative "qr"
|
|
5
|
+
require_relative "../review_start"
|
|
6
|
+
require_relative "../review_apply"
|
|
7
|
+
require_relative "../session_metrics"
|
|
8
|
+
require_relative "../participants"
|
|
9
|
+
require_relative "../session"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Web
|
|
13
|
+
# The owner's routes for a session's participants, mixed into App:
|
|
14
|
+
# invite, list, revoke, a participant's cap, and the review Apply (the
|
|
15
|
+
# owner's turn quoting a review turn's proposal). What a participant
|
|
16
|
+
# may do themselves is ParticipantAccess's. See
|
|
17
|
+
# docs/internals/participants.md.
|
|
18
|
+
module ParticipantsAdmin
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# GET /api/sessions/:id/participants, GET /api/participants: the
|
|
22
|
+
# grants (one session's, or every one), in every state, never a token
|
|
23
|
+
# or digest. One session's also carries +warning+: the share warning
|
|
24
|
+
# for a session that isn't a review session (ReviewStart.share_warning,
|
|
25
|
+
# null for one), which the panel shows before its Invite.
|
|
26
|
+
def handle_participants_list(_req, id = nil)
|
|
27
|
+
now = @grants.now
|
|
28
|
+
grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
|
|
29
|
+
return json_response(200, { participants: grants }) unless id
|
|
30
|
+
|
|
31
|
+
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
32
|
+
json_response(200, { participants: with_spend(id, grants), warning: warning })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# One session's grants with what each spent and how many turns they
|
|
36
|
+
# ran (its saved analytics: SessionMetrics.saved_by_author), for the
|
|
37
|
+
# owner's participants panel.
|
|
38
|
+
def with_spend(id, grants)
|
|
39
|
+
rows = SessionMetrics.saved_by_author(Session.session_dir(id, state_dir: default_state_dir))
|
|
40
|
+
grants.map do |grant|
|
|
41
|
+
row = rows[Steer.author_for(grant[:id])] || { turns: 0, cost: 0.0 }
|
|
42
|
+
grant.merge(spend: row[:cost].round(4), turns: row[:turns])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /api/sessions/:id/participants {name, kind?, ttl? (seconds),
|
|
47
|
+
# cap? (dollars)}: a new grant. The answer carries the token and the
|
|
48
|
+
# link once (chi keeps only its digest), and +warning+ for a session
|
|
49
|
+
# that isn't a review session (ReviewStart.share_warning; null for one).
|
|
50
|
+
def handle_participants_invite(req, id)
|
|
51
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
52
|
+
ttl = body["ttl"]
|
|
53
|
+
return error_response(400, "invalid_participant", "ttl is a number of seconds") unless ttl.nil? || ttl.is_a?(Integer)
|
|
54
|
+
|
|
55
|
+
cap = body["cap"]
|
|
56
|
+
return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
|
|
57
|
+
|
|
58
|
+
issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
|
|
59
|
+
ttl: ttl, cap: cap, state_dir: default_state_dir)
|
|
60
|
+
link = participant_link(req, id, issued.token)
|
|
61
|
+
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
62
|
+
json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
|
|
63
|
+
link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
64
|
+
rescue Grants::Invalid => e
|
|
65
|
+
error_response(400, "invalid_participant", e.message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# GET /api/sessions/:id/apply[?turn=T] (owner): the owner's turn that
|
|
69
|
+
# applies review turn T's proposal (ReviewApply: the reply quoted
|
|
70
|
+
# under one line of the owner's), for the composer: never sent from
|
|
71
|
+
# here. Without turn, the latest review turn with a reply. Read from
|
|
72
|
+
# the live worker's messages, else the saved ones.
|
|
73
|
+
# @return 200 {text, turn_id, author, author_name}; 404 not_found (no
|
|
74
|
+
# such turn, or no review turn); 400 not_review_turn; 409 no_reply
|
|
75
|
+
def handle_apply(req, id)
|
|
76
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
77
|
+
live = bridge_get_json(id, "snapshot")
|
|
78
|
+
messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
|
|
79
|
+
proposal = ReviewApply.find(messages, req.params["turn"])
|
|
80
|
+
json_response(200, { text: ReviewApply.compose(proposal), turn_id: proposal.turn_id, author: proposal.author,
|
|
81
|
+
author_name: proposal.author_name })
|
|
82
|
+
rescue ReviewApply::Refused => e
|
|
83
|
+
status = { not_found: 404, not_review_turn: 400, no_reply: 409 }.fetch(e.code)
|
|
84
|
+
error_response(status, e.code.to_s, e.message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# DELETE /api/sessions/:id/participants[?id=p_…], DELETE
|
|
88
|
+
# /api/participants: revokes one participant, a session's or every
|
|
89
|
+
# one's; their open streams end within a poll.
|
|
90
|
+
def handle_participants_revoke(req, id = nil)
|
|
91
|
+
grant_id = req.params["id"]
|
|
92
|
+
if grant_id && !Grants::ID_RE.match?(grant_id.to_s)
|
|
93
|
+
return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
revoked = Participants.revoke(@grants, session_id: id, id: grant_id, by: "owner", state_dir: default_state_dir)
|
|
97
|
+
now = @grants.now
|
|
98
|
+
json_response(200, { revoked: revoked.map { |grant| grant.public_h(now) } })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# POST /api/sessions/:id/participants/:participant/cap {cap}: the
|
|
102
|
+
# active participant's new spending cap (dollars), from their next
|
|
103
|
+
# turn on. 200 {participant}; 400 invalid_participant.
|
|
104
|
+
def handle_participants_cap(req, id, grant_id)
|
|
105
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
106
|
+
unless Grants::ID_RE.match?(grant_id.to_s)
|
|
107
|
+
return error_response(400, "invalid_participant", "not a participant id: #{grant_id.to_s[0, 40]}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
grant = Participants.set_cap(@grants, session_id: id, id: grant_id, cap: body["cap"], state_dir: default_state_dir)
|
|
111
|
+
return error_response(400, "invalid_participant", Participants.no_participant(grant_id, id)) unless grant
|
|
112
|
+
|
|
113
|
+
json_response(200, { participant: grant.public_h(@grants.now) })
|
|
114
|
+
rescue Grants::Invalid => e
|
|
115
|
+
error_response(400, "invalid_participant", e.message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The link a participant opens: this chi web's LAN address when it has
|
|
119
|
+
# one (the owner may be asking on loopback), else the Host asked; the
|
|
120
|
+
# token traded for the cookie, the session in the fragment.
|
|
121
|
+
def participant_link(req, id, token)
|
|
122
|
+
host = req.get_header("HTTP_HOST").to_s
|
|
123
|
+
port = host[/:(\d+)\z/, 1] || req.port.to_s
|
|
124
|
+
name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
|
|
125
|
+
"http://#{name}:#{port}/?token=#{token}#s=#{id}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// progress from the stream in between. A participant's comment is
|
|
7
7
|
// POST …/turn (text only); their queued ones come back with Dequeue.
|
|
8
8
|
import {
|
|
9
|
-
authorColor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
9
|
+
authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
10
10
|
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
|
|
11
11
|
} from "./participant_model.js";
|
|
12
12
|
|
|
@@ -160,7 +160,7 @@ function render() {
|
|
|
160
160
|
status.className = `p-status ${waiting ? "waiting" : session.status === "running" ? "running" : ""}`;
|
|
161
161
|
$("people").replaceChildren(...(data.participants || []).map((p) => {
|
|
162
162
|
const chip = el("span", "p-person");
|
|
163
|
-
chip.style.setProperty("--author-color", authorColor(
|
|
163
|
+
chip.style.setProperty("--author-color", authorColor(participantAuthor(p.id)));
|
|
164
164
|
chip.append(el("span", "p-dot"), `${p.name}${p.kind === "agent" ? " (agent)" : ""}`);
|
|
165
165
|
return chip;
|
|
166
166
|
}));
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// refusal in words, the stream's URL, and the live turn as filtered events
|
|
4
4
|
// build it (ParticipantView on the server decides what arrives at all).
|
|
5
5
|
|
|
6
|
+
// A participant's author: "p:" and its grant id, as the server writes it
|
|
7
|
+
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
8
|
+
export const PARTICIPANT_PREFIX = "p:";
|
|
9
|
+
export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
|
|
10
|
+
|
|
6
11
|
// Six author colours (tokens.css --author-1..6), one per participant id,
|
|
7
12
|
// the same on every page that shows them (the owner's badges too).
|
|
8
13
|
export const AUTHOR_SLOTS = 6;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// prompt's own author_name (a saved message, turn_started) comes first; a
|
|
5
5
|
// badge drawn before the list came, with neither, says UNNAMED until
|
|
6
6
|
// nameUnnamedBadges names it.
|
|
7
|
-
import { authorColor } from "./participant_model.js";
|
|
7
|
+
import { authorColor, participantAuthor } from "./participant_model.js";
|
|
8
8
|
|
|
9
9
|
export const UNNAMED = "a participant";
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@ let names = new Map();
|
|
|
13
13
|
// @return whether the names changed (the panel then names the badges drawn
|
|
14
14
|
// without them)
|
|
15
15
|
export function setParticipantNames(grants) {
|
|
16
|
-
const next = new Map((grants || []).filter((g) => g?.id).map((g) => [
|
|
16
|
+
const next = new Map((grants || []).filter((g) => g?.id).map((g) => [participantAuthor(g.id), String(g.name || "")]));
|
|
17
17
|
const changed = next.size !== names.size || [...next].some(([author, name]) => names.get(author) !== name);
|
|
18
18
|
names = next;
|
|
19
19
|
return changed;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
16
|
import { createChipPopover } from "./chip_popover.js";
|
|
17
|
-
import { authorColor } from "./participant_model.js";
|
|
17
|
+
import { authorColor, participantAuthor } from "./participant_model.js";
|
|
18
18
|
import { setParticipantNames } from "./participant_names.js";
|
|
19
19
|
|
|
20
20
|
// Seen this recently: here now (the chip's dot). The gate writes
|
|
@@ -63,7 +63,7 @@ export function rowModel(grant, now) {
|
|
|
63
63
|
kind: grant.kind,
|
|
64
64
|
active,
|
|
65
65
|
present,
|
|
66
|
-
color: authorColor(
|
|
66
|
+
color: authorColor(participantAuthor(grant.id)),
|
|
67
67
|
state: active ? (present ? "here now" : `seen ${agoText(grant.last_used_at, now)}`)
|
|
68
68
|
: grant.state === "expired" ? "expired" : `revoked${grant.revoked_by ? ` (${grant.revoked_by})` : ""}`,
|
|
69
69
|
expires: active ? `expires ${untilText(grant.expires_at, now)}` : "",
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "session"
|
|
|
11
11
|
require_relative "session_inbox"
|
|
12
12
|
require_relative "turn_note"
|
|
13
13
|
require_relative "steer"
|
|
14
|
+
require_relative "turn_origin"
|
|
14
15
|
require_relative "worker_idle_exit"
|
|
15
16
|
require_relative "worker_wakes"
|
|
16
17
|
require_relative "worker_inbound"
|
|
@@ -145,7 +146,7 @@ module Samagotchi
|
|
|
145
146
|
@discard = false
|
|
146
147
|
# The exit asked for is a restart (POST /exit restart: true).
|
|
147
148
|
@exit_restart = false
|
|
148
|
-
# The lines the running turn's drain merged ([prompt,
|
|
149
|
+
# The lines the running turn's drain merged ([prompt, TurnOrigin]), for a
|
|
149
150
|
# failed prompt turn to hand back; reset as each turn begins.
|
|
150
151
|
@merged_this_turn = []
|
|
151
152
|
# A delegate child's side (ChildRing): whether the running turn is
|
|
@@ -624,7 +625,7 @@ module Samagotchi
|
|
|
624
625
|
# A participant's turn queued before their link ended or their cap was
|
|
625
626
|
# reached doesn't start (chi web refuses new ones; this is the
|
|
626
627
|
# worker's check).
|
|
627
|
-
if (reason = participant_refusal(origin
|
|
628
|
+
if (reason = participant_refusal(origin.author))
|
|
628
629
|
return refuse_participant_turn(origin, reason)
|
|
629
630
|
end
|
|
630
631
|
|
|
@@ -632,7 +633,7 @@ module Samagotchi
|
|
|
632
633
|
# background tasks that finished since the last look.
|
|
633
634
|
absorb_context(before_turn: true)
|
|
634
635
|
@task_wakes.absorb
|
|
635
|
-
user_input(origin
|
|
636
|
+
user_input(origin.client_id)
|
|
636
637
|
@continue_offer.drop(origin)
|
|
637
638
|
@turn_flow.before_prompt_turn
|
|
638
639
|
run_engine_turn(prompt.text, origin: origin, max_iterations: max_iterations(prompt.no_interrupt),
|
|
@@ -644,7 +645,7 @@ module Samagotchi
|
|
|
644
645
|
@continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
|
|
645
646
|
end
|
|
646
647
|
end
|
|
647
|
-
participant_refusal(origin
|
|
648
|
+
participant_refusal(origin.author)
|
|
648
649
|
end
|
|
649
650
|
|
|
650
651
|
# Why the participant +author+ ("p:<id>") may run no more turns, nil
|
|
@@ -656,7 +657,7 @@ module Samagotchi
|
|
|
656
657
|
def participant_refusal(author)
|
|
657
658
|
author = Steer.sender_author(author) or return nil
|
|
658
659
|
|
|
659
|
-
entry = Participants.read(@session_id, state_dir: @state_dir).find { |e|
|
|
660
|
+
entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
|
|
660
661
|
return "revoked" unless entry&.active?(Time.now)
|
|
661
662
|
|
|
662
663
|
cap = entry.cap.is_a?(Numeric) ? entry.cap.to_f : Web::Grants.default_cap
|
|
@@ -673,7 +674,7 @@ module Samagotchi
|
|
|
673
674
|
return if (@cap_noticed ||= Set.new).include?(key)
|
|
674
675
|
|
|
675
676
|
@cap_noticed << key
|
|
676
|
-
id =
|
|
677
|
+
id = Steer.participant_id(author)
|
|
677
678
|
who = ParticipantName.clean(name)
|
|
678
679
|
@engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
|
|
679
680
|
text: format(CAP_NOTICE, name: who.empty? ? id : who, cap: cap, session: @session_id[0, 8], id: id))
|
|
@@ -686,11 +687,12 @@ module Samagotchi
|
|
|
686
687
|
# taken off the queue (:turn_dequeued, so every UI drops its bubble)
|
|
687
688
|
# and audited; it never runs. Its settled record (canceled, the reason)
|
|
688
689
|
# ends the sender's wait on it (chi send --wait, chi web's /wait).
|
|
690
|
+
# @param origin [TurnOrigin] a participant's
|
|
689
691
|
def refuse_participant_turn(origin, reason)
|
|
690
|
-
TurnCheckpoint.record_refused(@session_dir, origin
|
|
691
|
-
Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin
|
|
692
|
-
enqueued_id: origin
|
|
693
|
-
@engine.announce(type: :turn_dequeued, enqueued_id: origin
|
|
692
|
+
TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason)
|
|
693
|
+
Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
|
|
694
|
+
enqueued_id: origin.enqueued_id, status: "refused", reason: reason)
|
|
695
|
+
@engine.announce(type: :turn_dequeued, enqueued_id: origin.enqueued_id, client_id: origin.client_id)
|
|
694
696
|
nil
|
|
695
697
|
end
|
|
696
698
|
|
|
@@ -705,15 +707,18 @@ module Samagotchi
|
|
|
705
707
|
@engine.clear_due_reminder_names!
|
|
706
708
|
return false unless @engine.reminders_due?
|
|
707
709
|
|
|
708
|
-
@continue_offer.drop(
|
|
710
|
+
@continue_offer.drop(REMINDER_ORIGIN)
|
|
709
711
|
# A failure has no prompt to hand back (the Engine announced
|
|
710
712
|
# :turn_failed). The offer went before the turn
|
|
711
713
|
# (ContinueOffer#drop); either way the rollback window closes.
|
|
712
|
-
run_engine_turn(nil, continue: true, origin:
|
|
714
|
+
run_engine_turn(nil, continue: true, origin: REMINDER_ORIGIN,
|
|
713
715
|
max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
|
|
714
716
|
true
|
|
715
717
|
end
|
|
716
718
|
|
|
719
|
+
# A reminder turn's origin: chi's own (system:reminder).
|
|
720
|
+
REMINDER_ORIGIN = TurnOrigin.new(client_id: ClientId::REMINDER)
|
|
721
|
+
|
|
717
722
|
# :due when an idle parent should run a turn for its delegate
|
|
718
723
|
# children's reports now (session.delegate_reports: wake): rings wait,
|
|
719
724
|
# no continue offer does (they merge into its turn), wakes aren't paused
|
|
@@ -1107,13 +1112,13 @@ module Samagotchi
|
|
|
1107
1112
|
# Every kind of turn merges steering (a reminder turn too, which may be
|
|
1108
1113
|
# a fresh worker's first), so the list is this turn's from the start.
|
|
1109
1114
|
@merged_this_turn = []
|
|
1110
|
-
|
|
1115
|
+
origin = TurnOrigin.of(turn_args[:origin])
|
|
1116
|
+
@reporting_turn = reports_to_parent?(origin)
|
|
1111
1117
|
# A participant's review turn merges no input (#pending_input_drain).
|
|
1112
|
-
@participant_turn =
|
|
1118
|
+
@participant_turn = origin.participant?
|
|
1113
1119
|
restores = @turn_flow.restores
|
|
1114
1120
|
begin
|
|
1115
|
-
@checkpoint.begin_turn(prompt: prompt, enqueued_id:
|
|
1116
|
-
author: turn_author_fields(turn_args[:origin]))
|
|
1121
|
+
@checkpoint.begin_turn(prompt: prompt, enqueued_id: origin.enqueued_id, author: turn_author_fields(origin))
|
|
1117
1122
|
@session.status = Session::STATUS_RUNNING
|
|
1118
1123
|
# A continue that moved this session to the chain's new link wrote
|
|
1119
1124
|
# the new parent beside it (Session::PARENT_FILE): this turn's
|
|
@@ -1128,7 +1133,7 @@ module Samagotchi
|
|
|
1128
1133
|
# (ReplyWait) tells a turn's end by last_turn.
|
|
1129
1134
|
if @session.status == Session::STATUS_RUNNING
|
|
1130
1135
|
@session.status = Session::STATUS_IDLE
|
|
1131
|
-
@engine.record_last_turn(@session, "failed", 0,
|
|
1136
|
+
@engine.record_last_turn(@session, "failed", 0, origin, { type: :turn_failed })
|
|
1132
1137
|
Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
|
|
1133
1138
|
end
|
|
1134
1139
|
ensure
|
|
@@ -1142,7 +1147,7 @@ module Samagotchi
|
|
|
1142
1147
|
# no reply: its text is what came before its last tool calls, and a
|
|
1143
1148
|
# wait (chi send --wait) gets the question instead.
|
|
1144
1149
|
unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
|
|
1145
|
-
SessionInbox.write_output(@session_dir, response, enqueued_id:
|
|
1150
|
+
SessionInbox.write_output(@session_dir, response, enqueued_id: origin.enqueued_id)
|
|
1146
1151
|
end
|
|
1147
1152
|
save_or_log(:turn) { save_session }
|
|
1148
1153
|
# The reports the turn merged were read unless its end took the turn
|
|
@@ -1200,7 +1205,7 @@ module Samagotchi
|
|
|
1200
1205
|
def reports_to_parent?(origin)
|
|
1201
1206
|
return false unless @session.delegate?
|
|
1202
1207
|
|
|
1203
|
-
@initial_turn || @owes_parent || !ClientId.human?(origin
|
|
1208
|
+
@initial_turn || @owes_parent || !ClientId.human?(origin.client_id)
|
|
1204
1209
|
end
|
|
1205
1210
|
|
|
1206
1211
|
# The Engine published a question (after the question desk saved it).
|
|
@@ -1235,7 +1240,7 @@ module Samagotchi
|
|
|
1235
1240
|
# or the restored ones, never the one without the other. A turn that
|
|
1236
1241
|
# got somewhere stays instead (TurnFlow#prompt_turn_failed), with the
|
|
1237
1242
|
# prompts it took: nothing goes back.
|
|
1238
|
-
# @param prompts [Array<Array(String,
|
|
1243
|
+
# @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil)>] [prompt,
|
|
1239
1244
|
# origin, images] (a web client gets its image chips back)
|
|
1240
1245
|
# @param error [Exception, nil] what failed: its one line stays in the
|
|
1241
1246
|
# conversation as a turn note
|
|
@@ -1245,7 +1250,7 @@ module Samagotchi
|
|
|
1245
1250
|
next if @turn_flow.prompt_turn_failed(error: error) == :kept
|
|
1246
1251
|
|
|
1247
1252
|
prompts.each do |prompt, origin, images|
|
|
1248
|
-
restored = { type: :prompt_restored, prompt: prompt, origin: origin }
|
|
1253
|
+
restored = { type: :prompt_restored, prompt: prompt, origin: origin.wire }
|
|
1249
1254
|
restored[:images] = images unless Array(images).empty?
|
|
1250
1255
|
@engine.announce(restored)
|
|
1251
1256
|
end
|
|
@@ -1290,13 +1295,13 @@ module Samagotchi
|
|
|
1290
1295
|
FileUtils.rm_f(claimed_file)
|
|
1291
1296
|
end
|
|
1292
1297
|
end
|
|
1293
|
-
merged.each { |_prompt, origin| user_input(origin
|
|
1298
|
+
merged.each { |_prompt, origin| user_input(origin.client_id) }
|
|
1294
1299
|
# Delegate children's reports: never handed back on a failure
|
|
1295
1300
|
# (their rings stay for the next turn), so not in @merged_this_turn.
|
|
1296
1301
|
reports, mark = take_child_reports
|
|
1297
1302
|
unless merged.empty? && reports.empty?
|
|
1298
1303
|
@engine.announce(type: :input_merged, count: merged.size + reports.size,
|
|
1299
|
-
origins: merged.
|
|
1304
|
+
origins: (merged.map(&:last) + reports.map(&:origin)).filter_map(&:wire))
|
|
1300
1305
|
@merged_this_turn.concat(merged)
|
|
1301
1306
|
end
|
|
1302
1307
|
# Each line keeps its sender (Steer.source_for_client): a chi send or
|
|
@@ -1315,12 +1320,12 @@ module Samagotchi
|
|
|
1315
1320
|
|
|
1316
1321
|
# A participant's author fields (Engine#author_fields), {} for the owner.
|
|
1317
1322
|
def turn_author_fields(origin)
|
|
1318
|
-
|
|
1323
|
+
origin.participant? ? @engine.author_fields(origin.author, session: @session) : {}
|
|
1319
1324
|
end
|
|
1320
1325
|
|
|
1321
1326
|
def merged_line(prompt, origin)
|
|
1322
1327
|
author = turn_author_fields(origin)
|
|
1323
|
-
Steer::Line.new(text: prompt, source: Steer.source_for_client(origin
|
|
1328
|
+
Steer::Line.new(text: prompt, source: Steer.source_for_client(origin.client_id),
|
|
1324
1329
|
author: author[:author], author_name: author[:author_name])
|
|
1325
1330
|
end
|
|
1326
1331
|
|
|
@@ -12,6 +12,7 @@ require_relative "log"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
14
|
require_relative "steer"
|
|
15
|
+
require_relative "turn_origin"
|
|
15
16
|
require_relative "participants"
|
|
16
17
|
require_relative "tools/task_record"
|
|
17
18
|
require_relative "worker_wakes"
|
|
@@ -38,9 +39,11 @@ module Samagotchi
|
|
|
38
39
|
# +no_interrupt+: an offer its turn makes keeps it for its continue
|
|
39
40
|
# turn; +images+: its image refs ({file:, name:}); +delivery+: the wire
|
|
40
41
|
# value the sender asked for ("queue", or nil for the default
|
|
41
|
-
# next_step).
|
|
42
|
+
# next_step); +origin+: a TurnOrigin (a Hash is read as one).
|
|
42
43
|
Prompt = Data.define(:text, :origin, :no_interrupt, :images, :delivery) do
|
|
43
|
-
def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil)
|
|
44
|
+
def initialize(text:, origin: nil, no_interrupt: false, images: [], delivery: nil)
|
|
45
|
+
super(text:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
|
|
46
|
+
end
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
# A context wake turn to run: the ContextAbsorber::Delivery that woke
|
|
@@ -53,7 +56,7 @@ module Samagotchi
|
|
|
53
56
|
# Whether the wake counts toward context.peer_turns: a peer's answer
|
|
54
57
|
# that agrees closes the round and counts toward session.max_wakes only.
|
|
55
58
|
def peer_turn? = peer? && !delivery.agreed
|
|
56
|
-
def origin =
|
|
59
|
+
def origin = TurnOrigin.new(client_id: "#{ClientId::CONTEXT_PREFIX}#{name}")
|
|
57
60
|
# The plain note #unmark_wake puts back.
|
|
58
61
|
def note = delivery.note
|
|
59
62
|
end
|
|
@@ -65,7 +68,7 @@ module Samagotchi
|
|
|
65
68
|
# finished task it runs for (the others finished with it are plain notes).
|
|
66
69
|
TaskWake = Data.define(:id, :turn_id, :plain_note) do
|
|
67
70
|
def name = "task #{Tools::TaskRecord.short_id(id)}"
|
|
68
|
-
def origin =
|
|
71
|
+
def origin = TurnOrigin.new(client_id: "#{ClientId::TASK_PREFIX}#{id}")
|
|
69
72
|
def note = plain_note
|
|
70
73
|
end
|
|
71
74
|
|
|
@@ -192,7 +195,7 @@ module Samagotchi
|
|
|
192
195
|
# running for a turn that won't run.
|
|
193
196
|
# @return [Boolean] whether it was one
|
|
194
197
|
def initial_command?(prompt)
|
|
195
|
-
return false unless queue_as_command(prompt,
|
|
198
|
+
return false unless queue_as_command(prompt, TurnOrigin::NONE)
|
|
196
199
|
|
|
197
200
|
@session.status = Session::STATUS_IDLE
|
|
198
201
|
save(:initial_command, unless_stopped: true)
|
|
@@ -246,14 +249,14 @@ module Samagotchi
|
|
|
246
249
|
def queue_as_command(text, origin, after_file = nil)
|
|
247
250
|
return false unless @engine.command_registry.command?(text.to_s)
|
|
248
251
|
|
|
249
|
-
if
|
|
250
|
-
Log.warn(:worker, "participant_command_refused", participant:
|
|
251
|
-
Participants.log(@session.id, :refused, state_dir: @state_dir, participant:
|
|
252
|
+
if origin.participant?
|
|
253
|
+
Log.warn(:worker, "participant_command_refused", participant: origin.participant_author)
|
|
254
|
+
Participants.log(@session.id, :refused, state_dir: @state_dir, participant: origin.participant_id,
|
|
252
255
|
what: "command", reason: "not_permitted")
|
|
253
256
|
return true
|
|
254
257
|
end
|
|
255
258
|
|
|
256
|
-
@queue_command.call(text, origin
|
|
259
|
+
@queue_command.call(text, origin.client_id, after_file, origin.enqueued_id)
|
|
257
260
|
true
|
|
258
261
|
end
|
|
259
262
|
|