samagotchi 0.119.0 → 0.120.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 +66 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +45 -10
- data/docs/internals/participants.md +109 -12
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/sessions.md +4 -3
- data/docs/testing.md +10 -1
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +66 -8
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/index.html +7 -83
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +45 -10
- data/lib/samagotchi/web/public/participant.js +92 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +34 -10
- data/lib/samagotchi/web/public/review_history.js +52 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +9 -1
|
@@ -22,13 +22,16 @@ module Samagotchi
|
|
|
22
22
|
include CLI::Command
|
|
23
23
|
|
|
24
24
|
USAGE = <<~TEXT
|
|
25
|
-
Usage: chi share ID --name NAME [--agent] [--expires 3d] [--cap 2]
|
|
25
|
+
Usage: chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]
|
|
26
26
|
chi share ls [ID] [--format text|json]
|
|
27
27
|
chi share cap ID PARTICIPANT DOLLARS
|
|
28
28
|
chi share revoke ID [PARTICIPANT] | --all
|
|
29
29
|
ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
|
|
30
30
|
the LAN, web.host: lan) and its QR code at a terminal. The link is shown
|
|
31
31
|
once; chi keeps only a digest of its token.
|
|
32
|
+
--identity EMAIL bind the link to that account: it opens only through chi web's auth proxy
|
|
33
|
+
signed in as EMAIL (web.public_url with web.proxy.identity_header, or
|
|
34
|
+
chi web --tunnel tailscale); a forwarded link opens for nobody else
|
|
32
35
|
--agent the participant is an agent (the same link, used as a Bearer token)
|
|
33
36
|
--expires N Nh or Nd, 1h to 30d (default 7d, web.participants.expires)
|
|
34
37
|
--cap DOLLARS what their turns may cost you, $0 to $1000 (default $2, web.participants.cap)
|
|
@@ -50,6 +53,7 @@ module Samagotchi
|
|
|
50
53
|
# The invite's flags (chi share ID …), and ls's and revoke's.
|
|
51
54
|
FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
52
55
|
f.value "--name"
|
|
56
|
+
f.value "--identity"
|
|
53
57
|
f.switch "--agent"
|
|
54
58
|
f.value "--expires"
|
|
55
59
|
f.value "--cap"
|
|
@@ -91,9 +95,9 @@ module Samagotchi
|
|
|
91
95
|
return parsed if parsed.is_a?(Integer)
|
|
92
96
|
|
|
93
97
|
send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
|
|
94
|
-
rescue SessionAccess::BadRequest => e
|
|
98
|
+
rescue SessionAccess::BadRequest, Web::Grants::BadIdentity => e
|
|
95
99
|
usage_error(e.message)
|
|
96
|
-
rescue Web::Grants::
|
|
100
|
+
rescue Web::Grants::Unwritable => e
|
|
97
101
|
error_line("#{command_name}: #{e.message}")
|
|
98
102
|
CLI::Exit::FAILED
|
|
99
103
|
rescue ArgumentError => e
|
|
@@ -116,9 +120,13 @@ module Samagotchi
|
|
|
116
120
|
cap = options[:cap] && dollars(options[:cap])
|
|
117
121
|
return usage_error(CAP_USAGE) if cap == false
|
|
118
122
|
|
|
123
|
+
identity = options[:identity] && Web::Grants.clean_identity(options[:identity])
|
|
124
|
+
|
|
119
125
|
id = @access.resolve(args.first)
|
|
120
|
-
invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap
|
|
126
|
+
invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl, cap: cap,
|
|
127
|
+
identity: identity)
|
|
121
128
|
print_invitation(id, invitation)
|
|
129
|
+
warn_unbound(invitation.participant)
|
|
122
130
|
@changes = [[:joined, invitation.participant.id]]
|
|
123
131
|
0
|
|
124
132
|
end
|
|
@@ -186,10 +194,21 @@ module Samagotchi
|
|
|
186
194
|
0
|
|
187
195
|
end
|
|
188
196
|
|
|
197
|
+
# Decision B: an unbound link behind a proxy that could bind it opens
|
|
198
|
+
# for anyone the proxy lets in who holds it.
|
|
199
|
+
def warn_unbound(row)
|
|
200
|
+
return unless row.identity.nil? && (header = @access.identity_header)
|
|
201
|
+
|
|
202
|
+
error_line("#{command_name}: warning: #{format(UNBOUND_WARNING, header: header)}")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
UNBOUND_WARNING = "this link isn't bound to an account: anyone the proxy lets in who gets it can open it " \
|
|
206
|
+
"(--identity EMAIL binds it to the email %<header>s carries)"
|
|
207
|
+
|
|
189
208
|
def print_invitation(id, invitation)
|
|
190
209
|
row = invitation.participant
|
|
191
210
|
@stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires " \
|
|
192
|
-
"#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}")
|
|
211
|
+
"#{local(row.expires_at, "%Y-%m-%d %H:%M")} · cap #{money(row.cap)}#{" · bound to #{row.identity}" if row.identity}")
|
|
193
212
|
@stdout.puts(invitation.link)
|
|
194
213
|
@stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
|
|
195
214
|
if invitation.warning
|
|
@@ -201,12 +220,14 @@ module Samagotchi
|
|
|
201
220
|
@stdout.puts(Web::QR.lines(invitation.link)) if @tty
|
|
202
221
|
end
|
|
203
222
|
|
|
204
|
-
COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
|
|
223
|
+
COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "IDENTITY", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "CAP",
|
|
224
|
+
"STATE"].freeze
|
|
205
225
|
|
|
206
226
|
def print_table(rows)
|
|
207
227
|
cells = rows.map do |row|
|
|
208
|
-
[row.session_id[0, 8], row.id, row.name, row.
|
|
209
|
-
row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
|
|
228
|
+
[row.session_id[0, 8], row.id, row.name, row.identity ? "#{row.identity} · bound" : "-", row.kind, row.role,
|
|
229
|
+
local(row.created_at), local(row.expires_at), row.last_used_at ? local(row.last_used_at) : "-", money(row.cap),
|
|
230
|
+
row.state]
|
|
210
231
|
end
|
|
211
232
|
widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
|
|
212
233
|
[COLUMNS, *cells].each do |line|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../bridge_secret"
|
|
4
5
|
require_relative "../composer_route"
|
|
5
6
|
require_relative "event_renderer"
|
|
6
7
|
require_relative "formatting"
|
|
@@ -450,6 +451,8 @@ module Samagotchi
|
|
|
450
451
|
# The stream ended for good: why, and what brings the session back.
|
|
451
452
|
def closed_line(reason)
|
|
452
453
|
id = @client.session_id
|
|
454
|
+
# The worker asked for a secret this terminal's chi doesn't send.
|
|
455
|
+
return "Lost the session's worker: #{BridgeSecret::REFUSED}" if reason == BridgeSecret::ERROR
|
|
453
456
|
return remote_closed_line(reason, id) if via.remote?
|
|
454
457
|
|
|
455
458
|
# Here a stopped session has always read as unreachable.
|
|
@@ -55,12 +55,27 @@ module Samagotchi
|
|
|
55
55
|
id = @client.session_id
|
|
56
56
|
return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
# Kept: the stream moves to this very client (EventStream), and
|
|
59
|
+
# #switched takes it, port and secret, rather than reading again.
|
|
60
|
+
@found = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
|
|
61
|
+
[@found, nil]
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
# A new worker's Bridge
|
|
64
|
+
# A new worker's Bridge: the client #live_worker found for the stream
|
|
65
|
+
# (the event names its port), with that worker's secret. One the event
|
|
66
|
+
# doesn't name (another worker since) is read from the sidecar for that
|
|
67
|
+
# port, none when the sidecar names another one by now: that worker is
|
|
68
|
+
# gone too, and the stream finds the next.
|
|
62
69
|
def switched(event)
|
|
63
|
-
|
|
70
|
+
port = AttachLink.event_port(event)
|
|
71
|
+
found = @found
|
|
72
|
+
@client = if found&.port == port
|
|
73
|
+
found
|
|
74
|
+
else
|
|
75
|
+
sidecar = WorkerSidecar.read(session_dir)
|
|
76
|
+
sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
|
|
77
|
+
BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
|
|
78
|
+
end
|
|
64
79
|
end
|
|
65
80
|
|
|
66
81
|
# No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
|
|
@@ -139,6 +139,10 @@ module Samagotchi
|
|
|
139
139
|
def delivered(response, answer, fields, dismissed)
|
|
140
140
|
return "not delivered (the child's worker is gone)" unless response
|
|
141
141
|
|
|
142
|
+
# The child's Bridge asked for a secret this worker didn't send (one
|
|
143
|
+
# from before the child's chi): not the child's guardrails refusing.
|
|
144
|
+
return "not delivered (#{BridgeSecret::REFUSED})" if response.secret_refused?
|
|
145
|
+
|
|
142
146
|
hook = fields[:kind] == HOOK_KIND
|
|
143
147
|
case response.status
|
|
144
148
|
when 200
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -145,6 +145,8 @@ module Samagotchi
|
|
|
145
145
|
# with +public_url+ (the LAN one when both are set)
|
|
146
146
|
# @param push [Push::Runtime, nil] Web Push for this run (PushRoutes);
|
|
147
147
|
# nil: off (no https public_url, or web.push.enabled false)
|
|
148
|
+
# @param start_dir [String] the folder chi web starts in (StartDir):
|
|
149
|
+
# where a new session without a dir starts (nil: the cwd)
|
|
148
150
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
149
151
|
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
150
152
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
@@ -152,7 +154,8 @@ module Samagotchi
|
|
|
152
154
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
153
155
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
|
|
154
156
|
working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
|
|
155
|
-
grants: nil, public_url: nil, public_token: nil, push: nil)
|
|
157
|
+
grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil)
|
|
158
|
+
@start_dir = start_dir
|
|
156
159
|
@manager = manager || SessionManager
|
|
157
160
|
@registry = registry
|
|
158
161
|
@registry_given = !registry.nil?
|
|
@@ -490,7 +493,9 @@ module Samagotchi
|
|
|
490
493
|
|
|
491
494
|
# The token a trade presents: the page's ?token=, or POST /api/token's
|
|
492
495
|
# body's (read once): its token, or the token a one-time link code
|
|
493
|
-
# stands for (LinkCodes#redeem: used up here, whatever the gate says
|
|
496
|
+
# stands for (LinkCodes#redeem: used up here, whatever the gate says,
|
|
497
|
+
# but for a grant's code bound to another identity: decision C, kept
|
|
498
|
+
# for the right one, and refused token_bound, #code_token).
|
|
494
499
|
def traded_token(req)
|
|
495
500
|
return req.params["token"] unless fragment_trade?(req)
|
|
496
501
|
|
|
@@ -501,17 +506,38 @@ module Samagotchi
|
|
|
501
506
|
else parse_json(raw)
|
|
502
507
|
end
|
|
503
508
|
value = {} unless value.is_a?(Hash)
|
|
504
|
-
req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(value["code"]) : string_or_nil(value["token"])
|
|
509
|
+
req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(req, value["code"]) : string_or_nil(value["token"])
|
|
505
510
|
end
|
|
506
511
|
end
|
|
507
512
|
|
|
508
513
|
def string_or_nil(value) = value.is_a?(String) ? value : nil
|
|
509
514
|
|
|
510
|
-
|
|
511
|
-
|
|
515
|
+
# A bound grant's code is checked before it is used up: presented by
|
|
516
|
+
# another identity, or not through the proxy, it stays (the one it was
|
|
517
|
+
# sent to can still open it) and the trade is refused token_bound.
|
|
518
|
+
def code_token(req, code)
|
|
519
|
+
kind, token = @link_codes.redeem(code) do |found_kind, found_token|
|
|
520
|
+
found_kind == :owner || code_identity_ok?(req, found_token)
|
|
521
|
+
end
|
|
512
522
|
kind == :owner ? current_token : token
|
|
513
523
|
end
|
|
514
524
|
|
|
525
|
+
def code_identity_ok?(req, token)
|
|
526
|
+
grant = @grants.lookup(token)
|
|
527
|
+
# No grant to check it against (deleted, left out of a newer store,
|
|
528
|
+
# a store that doesn't read): kept, the trade refused.
|
|
529
|
+
return false if grant.nil?
|
|
530
|
+
return true unless grant.identity
|
|
531
|
+
|
|
532
|
+
proxied = proxied?(req)
|
|
533
|
+
state, identity = proxied ? proxy_identity(req) : [:none, nil]
|
|
534
|
+
identity = nil unless state == :ok
|
|
535
|
+
return true if bound_ok?(grant, identity, proxied)
|
|
536
|
+
|
|
537
|
+
refuse_bound(req, grant, identity)
|
|
538
|
+
false
|
|
539
|
+
end
|
|
540
|
+
|
|
515
541
|
TRADED_TOKEN_ENV = "samagotchi.traded_token"
|
|
516
542
|
|
|
517
543
|
def fragment_trade?(req) = req.post? && req.path_info == TOKEN_TRADE_PATH
|
|
@@ -566,7 +592,7 @@ module Samagotchi
|
|
|
566
592
|
# no longer works is cleared too, so the browser it was left in (the
|
|
567
593
|
# owner's, who opened a participant link) is the owner's again.
|
|
568
594
|
def refused_token(req)
|
|
569
|
-
response = grant_ended_response(req) || unauthorized(req)
|
|
595
|
+
response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
|
|
570
596
|
return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
|
|
571
597
|
|
|
572
598
|
status, headers, body = response
|
|
@@ -837,18 +863,27 @@ module Samagotchi
|
|
|
837
863
|
# workers it starts run that one, not this chi web's version;
|
|
838
864
|
# session_commands: the routes chi --remote needs
|
|
839
865
|
# (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
|
|
840
|
-
# attached TUI needs (ATTACH_FEATURE)
|
|
841
|
-
#
|
|
842
|
-
#
|
|
866
|
+
# attached TUI needs (ATTACH_FEATURE); bound_grants: an invite takes an
|
|
867
|
+
# identity (BOUND_FEATURE). lan: its LAN address, nil when it listens
|
|
868
|
+
# on loopback only; proxy: {identity_header} behind an auth proxy
|
|
869
|
+
# (web.public_url or a tunnel; the header nil when none is trusted),
|
|
870
|
+
# nil without one: the Invite dialog's email field and chi share's
|
|
871
|
+
# warning. A participant gets app, version and features only
|
|
843
872
|
# (#participant_info).
|
|
844
873
|
def handle_info(req)
|
|
845
874
|
return participant_info unless principal(req).owner?
|
|
846
875
|
|
|
847
876
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
|
|
848
|
-
cwd: Dir.pwd, features: INFO_FEATURES, lan: @lan && @lan[:ip]
|
|
877
|
+
cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
|
|
878
|
+
proxy: @public_url && { identity_header: @public_url.identity_header } })
|
|
849
879
|
end
|
|
850
880
|
|
|
851
|
-
|
|
881
|
+
# POST …/participants takes an identity (a grant bound to it): a chi
|
|
882
|
+
# web without it would ignore one and issue an unbound grant, so chi
|
|
883
|
+
# --remote share --identity asks for this first.
|
|
884
|
+
BOUND_FEATURE = "bound_grants"
|
|
885
|
+
INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE,
|
|
886
|
+
BOUND_FEATURE].freeze
|
|
852
887
|
|
|
853
888
|
# The models a new session can start on, spelled as chi spells them
|
|
854
889
|
# elsewhere: bare for the default host (what default.model holds), and
|
|
@@ -1049,6 +1084,10 @@ module Samagotchi
|
|
|
1049
1084
|
return not_live(id) unless client
|
|
1050
1085
|
|
|
1051
1086
|
reply = request.call(client)
|
|
1087
|
+
# Not the route's own 403 (a parent's option refused): the worker
|
|
1088
|
+
# asked for a secret this chi web didn't send.
|
|
1089
|
+
return error_response(502, BridgeSecret::ERROR, BridgeSecret::REFUSED) if reply.secret_refused?
|
|
1090
|
+
|
|
1052
1091
|
mapped = yield(reply)
|
|
1053
1092
|
return mapped if mapped
|
|
1054
1093
|
|
|
@@ -1095,8 +1134,8 @@ module Samagotchi
|
|
|
1095
1134
|
# The session's Bridge, waiting briefly for one a resume just spawned.
|
|
1096
1135
|
# @return [BridgeClient, nil]
|
|
1097
1136
|
def live_bridge_client(id)
|
|
1098
|
-
|
|
1099
|
-
|
|
1137
|
+
sidecar = bridge_sidecar(id) || await_bridge_sidecar(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
|
|
1138
|
+
bridge_client_for(id, sidecar)
|
|
1100
1139
|
end
|
|
1101
1140
|
|
|
1102
1141
|
# The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
|
|
@@ -1319,14 +1358,16 @@ module Samagotchi
|
|
|
1319
1358
|
# Single live transport: proxy to the session's Bridge. When no live
|
|
1320
1359
|
# worker exists there is nothing to stream — history is available via
|
|
1321
1360
|
# GET /api/sessions/:id/output.
|
|
1322
|
-
|
|
1361
|
+
sidecar = bridge_sidecar(id)
|
|
1323
1362
|
# A resume just spawned the worker: its bridge may still be binding.
|
|
1324
1363
|
# Wait briefly for it so the first SSE connect lands on a live bridge
|
|
1325
1364
|
# instead of an instantly-closed empty 200 / 503 during startup.
|
|
1326
|
-
if
|
|
1327
|
-
|
|
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)
|
|
1328
1367
|
end
|
|
1329
|
-
return error_response(503, "not_live", "no live bridge for session #{id}") unless
|
|
1368
|
+
return error_response(503, "not_live", "no live bridge for session #{id}") unless sidecar
|
|
1369
|
+
|
|
1370
|
+
client = bridge_client_for(id, sidecar)
|
|
1330
1371
|
|
|
1331
1372
|
headers = {
|
|
1332
1373
|
"Content-Type" => "text/event-stream",
|
|
@@ -1341,19 +1382,19 @@ module Samagotchi
|
|
|
1341
1382
|
# handler supports hijacking, stream frames straight to the socket
|
|
1342
1383
|
# instead of returning an enumerable.
|
|
1343
1384
|
if req.env["rack.hijack?"]
|
|
1344
|
-
hijack = sse_hijack(id, req,
|
|
1385
|
+
hijack = sse_hijack(id, req, client)
|
|
1345
1386
|
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1346
1387
|
end
|
|
1347
1388
|
|
|
1348
|
-
[200, headers, proxy_stream_body(id, req,
|
|
1389
|
+
[200, headers, proxy_stream_body(id, req, client)]
|
|
1349
1390
|
end
|
|
1350
1391
|
|
|
1351
1392
|
# The one ProxyStreamBody both of #handle_stream's transports send:
|
|
1352
1393
|
# the session's Bridge stream, through the request principal's frame
|
|
1353
1394
|
# filter (#stream_filter).
|
|
1354
|
-
def proxy_stream_body(id, req,
|
|
1395
|
+
def proxy_stream_body(id, req, client)
|
|
1355
1396
|
query = participant_view?(req) ? participant_stream_query(req) : stream_query(req)
|
|
1356
|
-
ProxyStreamBody.new(
|
|
1397
|
+
ProxyStreamBody.new(client: client, query: query, headers: req.env,
|
|
1357
1398
|
server_running: stream_running(principal(req)), filter: stream_filter(req, id))
|
|
1358
1399
|
end
|
|
1359
1400
|
|
|
@@ -1424,45 +1465,47 @@ module Samagotchi
|
|
|
1424
1465
|
# frames directly to the client socket until it disconnects. The handler
|
|
1425
1466
|
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
1426
1467
|
# only body bytes go to the socket here.
|
|
1427
|
-
def sse_hijack(id, req,
|
|
1468
|
+
def sse_hijack(id, req, client)
|
|
1428
1469
|
# Made before the status line goes out: a principal with no filter
|
|
1429
1470
|
# is refused (#stream_filter), never streamed.
|
|
1430
|
-
body = proxy_stream_body(id, req,
|
|
1471
|
+
body = proxy_stream_body(id, req, client)
|
|
1431
1472
|
lambda do |io|
|
|
1432
1473
|
body.each { |chunk| io.write(chunk) }
|
|
1433
1474
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
1434
1475
|
nil # client went away — end the stream quietly
|
|
1435
1476
|
rescue StandardError => e
|
|
1436
|
-
Log.warn(:web, "proxy_failed", sid: id, port: port, error: e.class.name, msg: e.message)
|
|
1477
|
+
Log.warn(:web, "proxy_failed", sid: id, port: client.port, error: e.class.name, msg: e.message)
|
|
1437
1478
|
nil
|
|
1438
1479
|
end
|
|
1439
1480
|
end
|
|
1440
1481
|
|
|
1441
1482
|
# Bounded wait for a freshly-spawned worker's bridge sidecar so the
|
|
1442
1483
|
# response hands the client a live transport without client-side polling.
|
|
1443
|
-
# Returns the bridge
|
|
1484
|
+
# Returns the bridge's sidecar (WorkerSidecar), or nil when it is not up by the deadline (the
|
|
1444
1485
|
# session still exists; EventSource auto-reconnect covers late binders).
|
|
1445
|
-
def
|
|
1486
|
+
def await_bridge_sidecar(session_id, timeout: @bridge_wait_timeout)
|
|
1446
1487
|
return nil if timeout.nil? || timeout <= 0
|
|
1447
1488
|
|
|
1448
|
-
BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) {
|
|
1489
|
+
BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar(session_id) }
|
|
1449
1490
|
rescue StandardError
|
|
1450
1491
|
nil
|
|
1451
1492
|
end
|
|
1452
1493
|
|
|
1453
|
-
#
|
|
1454
|
-
|
|
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)
|
|
1455
1499
|
dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
|
|
1456
|
-
BridgeClient.
|
|
1500
|
+
BridgeClient.live_sidecar(dir, host: DEFAULT_HOST)
|
|
1457
1501
|
rescue StandardError
|
|
1458
1502
|
nil
|
|
1459
1503
|
end
|
|
1460
1504
|
|
|
1461
1505
|
# @return [BridgeClient, nil] a client for the session's live Bridge
|
|
1462
|
-
def bridge_client(session_id)
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
end
|
|
1506
|
+
def bridge_client(session_id) = bridge_client_for(session_id, bridge_sidecar(session_id))
|
|
1507
|
+
|
|
1508
|
+
def bridge_client_for(session_id, sidecar) = BridgeClient.for_sidecar(session_id, sidecar, host: DEFAULT_HOST)
|
|
1466
1509
|
|
|
1467
1510
|
# GET a read path of the session's live Bridge (+path+: "state",
|
|
1468
1511
|
# "snapshot", "stats", ...).
|
|
@@ -1582,6 +1625,9 @@ module Samagotchi
|
|
|
1582
1625
|
@state_dir || Session.default_state_dir
|
|
1583
1626
|
end
|
|
1584
1627
|
|
|
1628
|
+
# The folder chi web starts in (StartDir), the cwd when none was given.
|
|
1629
|
+
def start_dir = @start_dir || Dir.pwd
|
|
1630
|
+
|
|
1585
1631
|
# The sessions folder as the page shows it: ~ for the home folder.
|
|
1586
1632
|
def sessions_dir_label
|
|
1587
1633
|
home_label(File.expand_path(default_state_dir))
|
|
@@ -1590,34 +1636,61 @@ module Samagotchi
|
|
|
1590
1636
|
def home_label(dir) = Paths.tilde(dir)
|
|
1591
1637
|
|
|
1592
1638
|
# <body> data for the page: where sessions are stored, where an
|
|
1593
|
-
# all-view new chat starts (the
|
|
1639
|
+
# all-view new chat starts (the start folder), and, when ?dir names a
|
|
1594
1640
|
# folder in a repo, that project's name and root. A bad dir adds
|
|
1595
1641
|
# nothing here: the list call answers 400 and the page says so.
|
|
1642
|
+
# Without ?dir (and without ?scope=all) the page is the start folder's
|
|
1643
|
+
# project view when that folder is in a repo: data-scope-dir names it
|
|
1644
|
+
# for the page (the URL stays bare, so a token's 303, a link code's
|
|
1645
|
+
# #code= and the app's start_url "/" all land there). ?dir= beats
|
|
1646
|
+
# ?scope=all.
|
|
1596
1647
|
def index_data_attributes(req)
|
|
1597
1648
|
# version: the chi that served the page; the events snapshot names
|
|
1598
1649
|
# the one serving now (an upgraded chi web after a reconnect).
|
|
1599
|
-
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(
|
|
1650
|
+
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
|
|
1600
1651
|
attrs["view"] = view_name(req.params["view"])
|
|
1601
1652
|
# Only when on: the page reads its presence (the turn view ignores it).
|
|
1602
1653
|
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
1603
|
-
|
|
1604
|
-
attrs["annotate-presets"] = @annotate_presets
|
|
1654
|
+
attrs.merge!(annotate_presets_data)
|
|
1605
1655
|
dir, = scope_dir(req.params["dir"])
|
|
1656
|
+
unless dir
|
|
1657
|
+
dir = default_scope_dir(req)
|
|
1658
|
+
attrs["scope-dir"] = dir if dir
|
|
1659
|
+
end
|
|
1606
1660
|
root = dir && ProjectScope.root_for(dir)
|
|
1607
1661
|
if root
|
|
1608
1662
|
attrs["project-name"] = File.basename(root)
|
|
1609
1663
|
attrs["project-dir"] = home_label(root)
|
|
1610
1664
|
elsif dir.nil?
|
|
1611
1665
|
# The all view's way back: the project view it came from
|
|
1612
|
-
# (?from=), else the project chi web
|
|
1666
|
+
# (?from=), else the project chi web starts in.
|
|
1613
1667
|
back, = scope_dir(req.params["from"])
|
|
1614
|
-
back ||=
|
|
1668
|
+
back ||= start_dir
|
|
1615
1669
|
if (back_root = ProjectScope.root_for(back))
|
|
1616
1670
|
attrs["back-name"] = File.basename(back_root)
|
|
1617
1671
|
attrs["back-dir"] = back
|
|
1618
1672
|
end
|
|
1619
1673
|
end
|
|
1620
|
-
attrs
|
|
1674
|
+
body_data(attrs)
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
# +attrs+ as <body> data attributes, each value escaped.
|
|
1678
|
+
def body_data(attrs) = attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
|
|
1679
|
+
|
|
1680
|
+
# The Annotate presets (web.annotate_presets) for a page's <body>, the
|
|
1681
|
+
# owner's and the participant page's. Always there: an empty one means
|
|
1682
|
+
# no presets, not the default.
|
|
1683
|
+
def annotate_presets_data = { "annotate-presets" => @annotate_presets }
|
|
1684
|
+
|
|
1685
|
+
# The folder a bare page (no ?dir, no ?scope=all) is scoped to: the
|
|
1686
|
+
# start folder when it is still a folder in a repo, else nil (every
|
|
1687
|
+
# session).
|
|
1688
|
+
def default_scope_dir(req)
|
|
1689
|
+
return nil unless req.params["dir"].to_s.strip.empty?
|
|
1690
|
+
return nil if req.params["scope"] == "all"
|
|
1691
|
+
|
|
1692
|
+
dir = File.expand_path(start_dir)
|
|
1693
|
+
File.directory?(dir) && ProjectScope.root_for(dir) ? dir : nil
|
|
1621
1694
|
end
|
|
1622
1695
|
|
|
1623
1696
|
VIEWS = Config::BY_KEY["web.view"].enum_values
|
|
@@ -1750,8 +1823,9 @@ module Samagotchi
|
|
|
1750
1823
|
# is kept (the env carries the token cookie, never held or inspected)
|
|
1751
1824
|
# @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
|
|
1752
1825
|
# it sees the Bridge's reads as they come, not whole frames
|
|
1753
|
-
|
|
1754
|
-
|
|
1826
|
+
# @param client [BridgeClient] the session's Bridge (App#bridge_client_for)
|
|
1827
|
+
def initialize(client:, query:, headers:, server_running: -> { true }, filter: nil)
|
|
1828
|
+
@client = client
|
|
1755
1829
|
@query = query
|
|
1756
1830
|
@last_event_id = headers["HTTP_LAST_EVENT_ID"]
|
|
1757
1831
|
@server_running = server_running
|