samagotchi 0.122.0 → 0.124.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 +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- metadata +7 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -4,6 +4,9 @@ require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "json"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
require "rubygems/version"
|
|
7
10
|
require "time"
|
|
8
11
|
require "uri"
|
|
9
12
|
require "rack"
|
|
@@ -67,10 +70,14 @@ require_relative "session_show"
|
|
|
67
70
|
require_relative "session_create"
|
|
68
71
|
require_relative "session_control"
|
|
69
72
|
require_relative "push_routes"
|
|
73
|
+
require_relative "update_check"
|
|
70
74
|
require_relative "proxy_gate"
|
|
71
75
|
require_relative "../log"
|
|
72
76
|
require_relative "../paths"
|
|
73
77
|
require_relative "../redacted_inspect"
|
|
78
|
+
require_relative "../child_env"
|
|
79
|
+
require_relative "../installed_gem"
|
|
80
|
+
require_relative "../live_versions"
|
|
74
81
|
|
|
75
82
|
module Samagotchi
|
|
76
83
|
module Web
|
|
@@ -113,6 +120,7 @@ module Samagotchi
|
|
|
113
120
|
# A callable the event loops ask whether the server still runs;
|
|
114
121
|
# Server.start points it at WEBrick's status once it has the server.
|
|
115
122
|
attr_writer :server_running
|
|
123
|
+
attr_writer :restart_web
|
|
116
124
|
|
|
117
125
|
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
118
126
|
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
@@ -156,7 +164,8 @@ module Samagotchi
|
|
|
156
164
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
157
165
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
|
|
158
166
|
working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
|
|
159
|
-
grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil
|
|
167
|
+
grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil, update_runner: nil,
|
|
168
|
+
update_blocked: nil)
|
|
160
169
|
@start_dir = start_dir
|
|
161
170
|
@manager = manager || SessionManager
|
|
162
171
|
@registry = registry
|
|
@@ -182,6 +191,10 @@ module Samagotchi
|
|
|
182
191
|
@refused_logged = {}
|
|
183
192
|
@wait_poll_interval = wait_poll_interval
|
|
184
193
|
@push = push
|
|
194
|
+
@update_mutex = Mutex.new
|
|
195
|
+
@update_running = false
|
|
196
|
+
@update_runner = update_runner || method(:run_update_command)
|
|
197
|
+
@update_blocked = update_blocked || -> { UpdateCheck.blocked_reason }
|
|
185
198
|
init_participant_access(grants, state_dir)
|
|
186
199
|
init_proxy_gate(public_url, public_token)
|
|
187
200
|
end
|
|
@@ -233,6 +246,9 @@ module Samagotchi
|
|
|
233
246
|
["GET", %r{\A/api/models\z}, :handle_models, :owner],
|
|
234
247
|
["GET", %r{\A/api/history\z}, :handle_history, :owner],
|
|
235
248
|
["GET", %r{\A/api/events\z}, :handle_events, :owner],
|
|
249
|
+
["POST", %r{\A/api/update\z}, :handle_update, :owner],
|
|
250
|
+
["POST", %r{\A/api/update/restart_workers\z}, :handle_restart_workers, :owner],
|
|
251
|
+
["POST", %r{\A/api/update/restart_web\z}, :handle_restart_web, :owner],
|
|
236
252
|
["POST", %r{\A/api/render\z}, :handle_render, :owner],
|
|
237
253
|
["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :participant],
|
|
238
254
|
["GET", %r{\A/api/sessions/([^/]+)/review\z}, :handle_review, :participant, :session],
|
|
@@ -649,6 +665,183 @@ module Samagotchi
|
|
|
649
665
|
# no token is asked of this request): nothing to do.
|
|
650
666
|
def handle_token(_req) = [204, { "Cache-Control" => "no-store" }, []]
|
|
651
667
|
|
|
668
|
+
def handle_update(req)
|
|
669
|
+
return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
|
|
670
|
+
|
|
671
|
+
raw = request_body(req, 16_385)
|
|
672
|
+
return error_response(413, "body_too_large", "update request is too large") if raw.bytesize > 16_384
|
|
673
|
+
|
|
674
|
+
payload = parse_json(raw)
|
|
675
|
+
return error_response(400, "invalid_update", "expected a JSON object with a version") unless payload.is_a?(Hash)
|
|
676
|
+
|
|
677
|
+
version = payload["version"]
|
|
678
|
+
# Gem::Version.correct? lets surrounding whitespace through; argv gets none.
|
|
679
|
+
unless version.is_a?(String) && !version.match?(/\s/) && Gem::Version.correct?(version)
|
|
680
|
+
return error_response(400, "invalid_version", "version must be a gem version")
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
version = Gem::Version.new(version).to_s
|
|
684
|
+
if Gem::Version.new(version) <= Gem::Version.new(VERSION)
|
|
685
|
+
return error_response(409, "update_not_newer", "version #{version} is not newer than this chi")
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Found out here, not from the hub: UpdateCheck (off with update.notify: off) is what tells it.
|
|
689
|
+
blocked = @update_blocked.call
|
|
690
|
+
return json_response(409, { error: "update_blocked", reason: blocked }) if blocked
|
|
691
|
+
|
|
692
|
+
already_running = @update_mutex.synchronize do
|
|
693
|
+
if @update_running
|
|
694
|
+
true
|
|
695
|
+
else
|
|
696
|
+
@update_running = true
|
|
697
|
+
false
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
return error_response(409, "update_running", "an update is already running") if already_running
|
|
701
|
+
|
|
702
|
+
@hub.publish_update(state: "running", rows: [], operation: "install", version: version)
|
|
703
|
+
Thread.new { perform_update(version) }
|
|
704
|
+
json_response(202, { state: "running", version: version })
|
|
705
|
+
rescue ThreadError => e
|
|
706
|
+
@update_mutex.synchronize { @update_running = false }
|
|
707
|
+
Log.warn(:web, "update_start_failed", error: e.class.name, msg: e.message)
|
|
708
|
+
@hub&.publish_update(state: "failed", rows: [
|
|
709
|
+
{ "component" => "chi update", "status" => "failed (couldn't start update)", "failed" => true, "kept" => [] }
|
|
710
|
+
], operation: "install")
|
|
711
|
+
error_response(503, "update_start_failed", "couldn't start chi update")
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def perform_update(version)
|
|
715
|
+
argv = [RbConfig.ruby, InstalledGem.wrapper, "update", "--to", version, "--no-desktop", "--json"]
|
|
716
|
+
said = nil
|
|
717
|
+
output, success = @update_runner.call(argv, env: ChildEnv.unbundled,
|
|
718
|
+
progress: lambda { |line|
|
|
719
|
+
said = line unless line.strip.empty?
|
|
720
|
+
@hub.publish_update(state: "running", rows: [], progress: line,
|
|
721
|
+
operation: "install", version: version)
|
|
722
|
+
})
|
|
723
|
+
rows = update_rows(output)
|
|
724
|
+
# No rows (a usage error, a refusal): what the child said is the reason.
|
|
725
|
+
raise UpdateFailed, said || output.lines.map(&:strip).find { |line| !line.empty? } || "chi update printed no rows" unless rows
|
|
726
|
+
|
|
727
|
+
# The installed version as of now (the hub's own look is up to a probe interval old), before "done".
|
|
728
|
+
@hub.refresh_installed if success
|
|
729
|
+
@hub.publish_update(state: success ? "done" : "failed", rows: rows, operation: "install", version: version)
|
|
730
|
+
rescue StandardError => e
|
|
731
|
+
Log.exception(:web, "update_failed", e)
|
|
732
|
+
@hub.publish_update(state: "failed", rows: [
|
|
733
|
+
{ "component" => "chi update", "status" => "failed (#{e.message.lines.first.to_s.strip})",
|
|
734
|
+
"failed" => true, "kept" => [] }
|
|
735
|
+
], operation: "install", version: version)
|
|
736
|
+
ensure
|
|
737
|
+
@update_mutex.synchronize { @update_running = false }
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# chi update's own words when it printed no rows.
|
|
741
|
+
class UpdateFailed < StandardError; end
|
|
742
|
+
|
|
743
|
+
# chi update --json's rows, or nil when it printed none.
|
|
744
|
+
def update_rows(output)
|
|
745
|
+
rows = JSON.parse(output)
|
|
746
|
+
rows if rows.is_a?(Array) && rows.all?(Hash)
|
|
747
|
+
rescue JSON::ParserError
|
|
748
|
+
nil
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def run_update_command(argv, env:, progress:)
|
|
752
|
+
Open3.popen3(env, *argv) do |stdin, stdout, stderr, wait_thread|
|
|
753
|
+
stdin.close
|
|
754
|
+
stderr_thread = Thread.new { stderr.each_line { |line| progress.call(line.chomp) } }
|
|
755
|
+
output = stdout.read
|
|
756
|
+
stderr_thread.value
|
|
757
|
+
[output, wait_thread.value.success?]
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def handle_restart_workers(_req)
|
|
762
|
+
return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
|
|
763
|
+
|
|
764
|
+
already_running = @update_mutex.synchronize do
|
|
765
|
+
if @update_running
|
|
766
|
+
true
|
|
767
|
+
else
|
|
768
|
+
@update_running = true
|
|
769
|
+
false
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
return error_response(409, "update_running", "a chi update operation is already running") if already_running
|
|
773
|
+
|
|
774
|
+
@hub.publish_update(state: "running", rows: [], operation: "workers")
|
|
775
|
+
Thread.new { perform_worker_restarts }
|
|
776
|
+
json_response(202, { state: "running" })
|
|
777
|
+
rescue ThreadError => e
|
|
778
|
+
@update_mutex.synchronize { @update_running = false }
|
|
779
|
+
Log.warn(:web, "worker_restart_start_failed", error: e.class.name, msg: e.message)
|
|
780
|
+
@hub&.publish_update(state: "failed", rows: [
|
|
781
|
+
{ "state" => "failed", "reason" => "couldn't start worker restarts" }
|
|
782
|
+
], operation: "workers")
|
|
783
|
+
error_response(503, "restart_start_failed", "couldn't start worker restarts")
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def perform_worker_restarts
|
|
787
|
+
# Installed now: the hub's own look is up to a full probe old (nil right after chi web started).
|
|
788
|
+
target = @hub.refresh_installed || VERSION
|
|
789
|
+
rows = []
|
|
790
|
+
LiveVersions.stale_workers(target, state_dir: default_state_dir).each do |worker|
|
|
791
|
+
row = begin
|
|
792
|
+
restarted = @manager.restart_session(worker.session_id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
|
|
793
|
+
@hub.touch(worker.session_id)
|
|
794
|
+
{ "session_id" => restarted.session_id, "state" => "restarted", "from_version" => restarted.from_version,
|
|
795
|
+
"version" => restarted.version }
|
|
796
|
+
rescue SessionManager::RestartRefused => e
|
|
797
|
+
# detail: the refusal's own words (RestartRefused.words), the reason code for clients.
|
|
798
|
+
{ "session_id" => worker.session_id, "state" => "refused", "reason" => e.reason.to_s,
|
|
799
|
+
"detail" => e.message, "holds" => e.holds.map(&:to_s) }
|
|
800
|
+
rescue SessionManager::OwnedByTUI => e
|
|
801
|
+
{ "session_id" => worker.session_id, "state" => "owned_by_tui", "detail" => e.message }
|
|
802
|
+
rescue StandardError => e
|
|
803
|
+
Log.exception(:web, "worker_restart_failed", e, sid: worker.session_id)
|
|
804
|
+
{ "session_id" => worker.session_id, "state" => "failed", "reason" => e.class.name, "detail" => e.message }
|
|
805
|
+
end
|
|
806
|
+
rows << row
|
|
807
|
+
@hub.publish_update(state: "running", rows: rows.dup, operation: "workers")
|
|
808
|
+
end
|
|
809
|
+
state = rows.any? { |row| row["state"] == "failed" } ? "failed" : "done"
|
|
810
|
+
@hub.publish_update(state: state, rows: rows, operation: "workers")
|
|
811
|
+
rescue StandardError => e
|
|
812
|
+
Log.exception(:web, "worker_restart_failed", e)
|
|
813
|
+
@hub.publish_update(state: "failed", rows: [
|
|
814
|
+
{ "state" => "failed", "reason" => e.class.name, "detail" => e.message }
|
|
815
|
+
], operation: "workers")
|
|
816
|
+
ensure
|
|
817
|
+
@update_mutex.synchronize { @update_running = false }
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# The exec replaces this process: an update's chi update child (its
|
|
821
|
+
# pipes read here) or a worker restart pass would die mid-way, so
|
|
822
|
+
# neither may run, and none starts once the restart is asked for.
|
|
823
|
+
def handle_restart_web(_req)
|
|
824
|
+
return error_response(503, "restart_unavailable", "chi web cannot restart itself in this process") unless @restart_web
|
|
825
|
+
|
|
826
|
+
busy = @update_mutex.synchronize do
|
|
827
|
+
next true if @update_running
|
|
828
|
+
|
|
829
|
+
@update_running = true # held for good once restarting: the exec ends this process
|
|
830
|
+
false
|
|
831
|
+
end
|
|
832
|
+
return error_response(409, "update_running", "a chi update operation is running; restart chi web when it ends") if busy
|
|
833
|
+
|
|
834
|
+
case @restart_web.call
|
|
835
|
+
when true then return json_response(202, { state: "restarting" })
|
|
836
|
+
when :no_wrapper
|
|
837
|
+
refusal = error_response(409, "restart_unavailable", "no chi wrapper to restart into (installed with --bindir?); " \
|
|
838
|
+
"restart chi web in its terminal")
|
|
839
|
+
else refusal = error_response(409, "restart_requested", "chi web is already restarting")
|
|
840
|
+
end
|
|
841
|
+
@update_mutex.synchronize { @update_running = false }
|
|
842
|
+
refusal
|
|
843
|
+
end
|
|
844
|
+
|
|
652
845
|
def unauthorized(req)
|
|
653
846
|
reason = req.env[REFUSED_ENV]
|
|
654
847
|
log_refused(refused_peer(req), req.path_info, reason)
|
|
@@ -865,8 +1058,8 @@ module Samagotchi
|
|
|
865
1058
|
end
|
|
866
1059
|
|
|
867
1060
|
# What a second `chi web` probes before starting its own server: this
|
|
868
|
-
# is chi web, and it knows ?dir (features).
|
|
869
|
-
#
|
|
1061
|
+
# is chi web, and it knows ?dir (features). Its chi version state is
|
|
1062
|
+
# {version, installed, available, update_blocked}; newest_workers: the
|
|
870
1063
|
# workers it starts run that one, not this chi web's version;
|
|
871
1064
|
# session_commands: the routes chi --remote needs
|
|
872
1065
|
# (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
|
|
@@ -880,7 +1073,8 @@ module Samagotchi
|
|
|
880
1073
|
def handle_info(req)
|
|
881
1074
|
return participant_info unless principal(req).owner?
|
|
882
1075
|
|
|
883
|
-
|
|
1076
|
+
chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil }
|
|
1077
|
+
json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
|
|
884
1078
|
cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
|
|
885
1079
|
proxy: @public_url && { identity_header: @public_url.identity_header } })
|
|
886
1080
|
end
|
|
@@ -1799,14 +1993,15 @@ module Samagotchi
|
|
|
1799
1993
|
@queue = Bridge::BoundedQueue.new(capacity: capacity)
|
|
1800
1994
|
@handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
|
|
1801
1995
|
# Read after subscribing: a change in between is also a queued event.
|
|
1802
|
-
@
|
|
1996
|
+
@chi_state = @hub.chi_state
|
|
1803
1997
|
end
|
|
1804
1998
|
|
|
1805
1999
|
def each
|
|
1806
|
-
#
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
2000
|
+
# The page compares this full chi state with the one it was served by.
|
|
2001
|
+
snapshot = { sessions: @snapshot }.merge(@chi_state)
|
|
2002
|
+
update_state = @hub.update_state
|
|
2003
|
+
snapshot[:chi_update] = update_state if update_state
|
|
2004
|
+
yield frame(nil, "snapshot", snapshot)
|
|
1810
2005
|
last_write = monotonic
|
|
1811
2006
|
loop do
|
|
1812
2007
|
event = @queue.pop([@heartbeat, EVENTS_POLL].min)
|
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "../steer"
|
|
7
|
+
require_relative "../participant_mode"
|
|
7
8
|
require "securerandom"
|
|
8
9
|
require "time"
|
|
9
10
|
require "rack/utils"
|
|
@@ -54,6 +55,11 @@ module Samagotchi
|
|
|
54
55
|
KINDS = %w[human agent].freeze
|
|
55
56
|
# The only role in v1: a reviewer reads and (P3) comments.
|
|
56
57
|
ROLES = %w[reviewer].freeze
|
|
58
|
+
# The participant mode a grant was issued for (ParticipantMode): "pair"
|
|
59
|
+
# only when the owner was shown that a participant's turns act
|
|
60
|
+
# (ReviewStart::PAIR_WARNING); none for every other grant, and every
|
|
61
|
+
# grant from before it, whose turns stay review turns.
|
|
62
|
+
PAIR_MODE = ParticipantMode::PAIR.to_s
|
|
57
63
|
NAME_MAX = ParticipantName::MAX
|
|
58
64
|
ID_RE = /\Ap_[0-9a-f]{8}\z/
|
|
59
65
|
# D7: what a participant's turns (and the side calls they cause) may
|
|
@@ -137,12 +143,15 @@ module Samagotchi
|
|
|
137
143
|
# +extra+: the stored keys this chi doesn't know (a newer chi's,
|
|
138
144
|
# such as a field added to a grant without a version bump), written
|
|
139
145
|
# back as they were, so a rewrite here never drops them.
|
|
146
|
+
# +mode+: PAIR_MODE when the owner invited them to act (Grants#issue's
|
|
147
|
+
# mode:), nil otherwise. Stored only when set; a chi that doesn't know
|
|
148
|
+
# it keeps it in +extra+ and runs their turns as review turns.
|
|
140
149
|
Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
141
|
-
:revoked_by, :last_used_at, :cap, :queue, :identity, :exposure, :extra) do
|
|
150
|
+
:revoked_by, :last_used_at, :cap, :queue, :identity, :exposure, :mode, :extra) do
|
|
142
151
|
# identity: the proxy identity (an email, lower case) the grant is
|
|
143
152
|
# bound to, nil for any (ProxyGate; issued from shared-sessions P6).
|
|
144
|
-
def initialize(cap: nil, queue: nil, identity: nil, exposure: nil, extra: {}, **)
|
|
145
|
-
super(cap: cap, queue: queue, identity: identity, extra: extra,
|
|
153
|
+
def initialize(cap: nil, queue: nil, identity: nil, exposure: nil, mode: nil, extra: {}, **)
|
|
154
|
+
super(cap: cap, queue: queue, identity: identity, extra: extra, mode: mode,
|
|
146
155
|
exposure: exposure.nil? ? ParticipantExposure::DEFAULT : exposure, **)
|
|
147
156
|
end
|
|
148
157
|
|
|
@@ -198,6 +207,7 @@ module Samagotchi
|
|
|
198
207
|
def to_store
|
|
199
208
|
hash = to_h.except(:extra).transform_keys(&:to_s)
|
|
200
209
|
hash.delete("identity") if identity.nil?
|
|
210
|
+
hash.delete("mode") if mode.nil?
|
|
201
211
|
hash.delete("exposure") if exposure == ParticipantExposure::DEFAULT
|
|
202
212
|
hash.merge(extra.reject { |key, _| hash.key?(key) })
|
|
203
213
|
end
|
|
@@ -315,7 +325,8 @@ module Samagotchi
|
|
|
315
325
|
# @param on_write [#call, nil] see #update
|
|
316
326
|
# @return [Issued]
|
|
317
327
|
# @raise [Invalid]
|
|
318
|
-
def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, identity: nil,
|
|
328
|
+
def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, identity: nil, mode: nil,
|
|
329
|
+
on_write: nil)
|
|
319
330
|
Session.check_id!(session_id)
|
|
320
331
|
ttl ||= Grants.default_ttl
|
|
321
332
|
cap = Grants.default_cap if cap.nil?
|
|
@@ -323,6 +334,7 @@ module Samagotchi
|
|
|
323
334
|
raise Invalid, "role is reviewer, not #{role}" unless ROLES.include?(role)
|
|
324
335
|
raise Invalid, "a grant lasts 1 hour to 30 days" unless ttl.is_a?(Integer) && ttl.between?(3600, MAX_TTL)
|
|
325
336
|
raise Invalid, CAP_RANGE unless Grants.cap?(cap)
|
|
337
|
+
raise Invalid, "mode is #{PAIR_MODE} or none, not #{mode}" unless mode.nil? || mode == PAIR_MODE
|
|
326
338
|
|
|
327
339
|
clean = Grants.clean_name(name)
|
|
328
340
|
identity = Grants.clean_identity(identity) unless identity.nil?
|
|
@@ -333,7 +345,7 @@ module Samagotchi
|
|
|
333
345
|
grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
|
|
334
346
|
role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
|
|
335
347
|
revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue,
|
|
336
|
-
identity: identity)
|
|
348
|
+
identity: identity, mode: mode)
|
|
337
349
|
[grants + [grant], Issued.new(grant: grant, token: token)]
|
|
338
350
|
end
|
|
339
351
|
end
|
|
@@ -136,7 +136,12 @@ module Samagotchi
|
|
|
136
136
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
137
137
|
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
138
138
|
# A participant's message names them (the page's author badge).
|
|
139
|
-
|
|
139
|
+
if norm_role == "user"
|
|
140
|
+
message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"]))
|
|
141
|
+
# Its turn's mode (review or pair: the badge, Apply under a review turn only).
|
|
142
|
+
mode = m[:participant_mode] || m["participant_mode"]
|
|
143
|
+
message[:participant_mode] = mode.to_s if mode && message[:author]
|
|
144
|
+
end
|
|
140
145
|
images = m[:images] || m["images"]
|
|
141
146
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
142
147
|
if norm_role == "assistant"
|
|
@@ -50,7 +50,7 @@ module Samagotchi
|
|
|
50
50
|
# current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
|
|
51
51
|
def handle_review(req, id)
|
|
52
52
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
53
|
-
mode = ParticipantMode.
|
|
53
|
+
mode = ParticipantMode.live(session, state_dir: default_state_dir).to_s
|
|
54
54
|
viewer = participant_viewer(req, session)
|
|
55
55
|
live = bridge_get_json(id, "snapshot")
|
|
56
56
|
turn_snapshot = live && live["snapshot"]
|
|
@@ -156,6 +156,8 @@ module Samagotchi
|
|
|
156
156
|
|
|
157
157
|
grant = principal(req).grant
|
|
158
158
|
capped = !participant_cap_refusal(grant).nil?
|
|
159
|
+
# Theirs: pair only when their grant was issued to act (ParticipantMode.live_for).
|
|
160
|
+
mode = ParticipantMode.live_for(session, grant, state_dir: default_state_dir).to_s
|
|
159
161
|
{ kind: "participant", id: grant.id, name: grant.name, participant_kind: grant.kind, mode: mode, can_comment: !capped,
|
|
160
162
|
cap_reached: capped, session_id: id,
|
|
161
163
|
usage: review_usage(grant, session: session, state: state, queued: queued, viewer: viewer) }
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../output_formatter"
|
|
4
4
|
require_relative "../steer"
|
|
5
5
|
require_relative "../turn_origin"
|
|
6
|
+
require_relative "../participant_mode"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Web
|
|
@@ -36,6 +37,9 @@ module Samagotchi
|
|
|
36
37
|
def own?(author) = !self.author.nil? && author.to_s == self.author
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
# A turn's participant_mode values the view passes on (ParticipantMode).
|
|
41
|
+
PARTICIPANT_MODES = [ParticipantMode::REVIEW.to_s, ParticipantMode::PAIR.to_s].freeze
|
|
42
|
+
|
|
39
43
|
# The stream's event types a participant gets, each rebuilt by the
|
|
40
44
|
# method named. Every other type is dropped.
|
|
41
45
|
EVENTS = {
|
|
@@ -109,7 +113,7 @@ module Samagotchi
|
|
|
109
113
|
origin = TurnOrigin.of(field(event, :origin))
|
|
110
114
|
author = field(event, :author) || origin.author
|
|
111
115
|
out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
|
|
112
|
-
out.merge!(author_fields(author, field(event, :author_name), viewer))
|
|
116
|
+
out.merge!(author_fields(author, field(event, :author_name), viewer, field(event, :participant_mode)))
|
|
113
117
|
prompt = field(event, :prompt)
|
|
114
118
|
if field(event, :continue)
|
|
115
119
|
out["continue"] = true
|
|
@@ -202,7 +206,8 @@ module Samagotchi
|
|
|
202
206
|
out["content"] = field(message, :content).to_s
|
|
203
207
|
out["turn_id"] = turn_id.to_s if turn_id
|
|
204
208
|
out["merged"] = true if merged
|
|
205
|
-
out.merge!(author_fields(field(message, :author), field(message, :author_name), viewer
|
|
209
|
+
out.merge!(author_fields(field(message, :author), field(message, :author_name), viewer,
|
|
210
|
+
field(message, :participant_mode)))
|
|
206
211
|
images = field(message, :images)
|
|
207
212
|
out["images"] = images.size if images.is_a?(Array) && !images.empty?
|
|
208
213
|
out
|
|
@@ -277,12 +282,15 @@ module Samagotchi
|
|
|
277
282
|
end
|
|
278
283
|
|
|
279
284
|
# A turn's author as a participant sees it: the participant's id and
|
|
280
|
-
# name, or none for the owner's; +mine+ for the viewer's own
|
|
281
|
-
|
|
285
|
+
# name, or none for the owner's; +mine+ for the viewer's own; the
|
|
286
|
+
# turn's +mode+ (its participant_mode stamp: review or pair) when
|
|
287
|
+
# it has one.
|
|
288
|
+
def author_fields(author, name, viewer, mode = nil)
|
|
282
289
|
return {} unless Steer.participant_author?(author)
|
|
283
290
|
|
|
284
291
|
out = { "author" => author.to_s }
|
|
285
292
|
out["author_name"] = name.to_s if name.is_a?(String) && !name.empty?
|
|
293
|
+
out["participant_mode"] = mode.to_s if PARTICIPANT_MODES.include?(mode.to_s)
|
|
286
294
|
out["mine"] = true if viewer.own?(author)
|
|
287
295
|
out
|
|
288
296
|
end
|
|
@@ -24,14 +24,16 @@ module Samagotchi
|
|
|
24
24
|
# for a session that isn't a review session (ReviewStart.share_warning,
|
|
25
25
|
# null for one), which the panel shows before its Invite; and
|
|
26
26
|
# +identity_header+: the header this chi web's auth proxy names the
|
|
27
|
-
# signed-in user in (null without one), for the Invite's email field
|
|
27
|
+
# signed-in user in (null without one), for the Invite's email field;
|
|
28
|
+
# +mode+: what a participant invited now gets, "pair" or "review"
|
|
29
|
+
# (ReviewStart.share_terms), for the panel's words.
|
|
28
30
|
def handle_participants_list(_req, id = nil)
|
|
29
31
|
now = @grants.now
|
|
30
32
|
grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
|
|
31
33
|
return json_response(200, { participants: grants }) unless id
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
json_response(200, { participants: with_spend(id, grants), warning: warning,
|
|
35
|
+
terms = ReviewStart.share_terms(Session.load(id, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
36
|
+
json_response(200, { participants: with_spend(id, grants), warning: terms.warning, mode: terms.mode.to_s,
|
|
35
37
|
identity_header: @public_url&.identity_header })
|
|
36
38
|
rescue Grants::Unwritable => e
|
|
37
39
|
grants_unwritable(e)
|
|
@@ -67,13 +69,15 @@ module Samagotchi
|
|
|
67
69
|
cap = body["cap"]
|
|
68
70
|
return error_response(400, "invalid_participant", Grants::CAP_RANGE) unless cap.nil? || Grants.cap?(cap)
|
|
69
71
|
|
|
72
|
+
# The grant is stamped pair only when the warning that comes back says
|
|
73
|
+
# their turns act (one read: ReviewStart.share_terms).
|
|
74
|
+
terms = ReviewStart.share_terms(Session.load(id, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
70
75
|
issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
|
|
71
76
|
ttl: ttl, cap: cap, identity: body["identity"], public_url: @public_url,
|
|
72
|
-
state_dir: default_state_dir)
|
|
77
|
+
mode: terms.grant_mode, state_dir: default_state_dir)
|
|
73
78
|
link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
|
|
74
|
-
warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
|
|
75
79
|
json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
|
|
76
|
-
link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
|
|
80
|
+
link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: terms.warning })
|
|
77
81
|
rescue Grants::Invalid => e
|
|
78
82
|
error_response(400, "invalid_participant", e.message)
|
|
79
83
|
rescue Grants::Unwritable => e
|
|
@@ -26,6 +26,9 @@ import {
|
|
|
26
26
|
addContext,
|
|
27
27
|
renderText,
|
|
28
28
|
getWorkingSet,
|
|
29
|
+
updateChi,
|
|
30
|
+
restartWorkers,
|
|
31
|
+
restartWeb,
|
|
29
32
|
onAuthLost,
|
|
30
33
|
} from "./data.js";
|
|
31
34
|
import { authProbe, createAuthLost } from "./auth_lost.js";
|
|
@@ -68,7 +71,7 @@ import { readStored, writeStored } from "./storage.js";
|
|
|
68
71
|
import { createCommandList } from "./command_list.js";
|
|
69
72
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
70
73
|
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
71
|
-
import { render } from "./vendor/lit-html.js";
|
|
74
|
+
import { html, render } from "./vendor/lit-html.js";
|
|
72
75
|
import { shouldFollowScroll } from "./scroll.js";
|
|
73
76
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
74
77
|
import { createStripGrip } from "./strip_grip.js";
|
|
@@ -76,7 +79,7 @@ import { waitingOn } from "./notify.js";
|
|
|
76
79
|
import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
|
|
77
80
|
import { createNotifications } from "./notifications.js";
|
|
78
81
|
import { renameShown, tabTitle, topBarTitle } from "./session_title.js";
|
|
79
|
-
import { restartConfirmText, versionNotice } from "./update_notice.js";
|
|
82
|
+
import { mergeVersionState, restartConfirmText, versionNotice } from "./update_notice.js";
|
|
80
83
|
import {
|
|
81
84
|
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
82
85
|
} from "./timing.js";
|
|
@@ -100,6 +103,7 @@ import { createUnqueue } from "./unqueue.js";
|
|
|
100
103
|
import { createStartPage } from "./start_page.js";
|
|
101
104
|
import { createSessionActions } from "./session_actions.js";
|
|
102
105
|
import { createStopSheet } from "./stop_sheet.js";
|
|
106
|
+
import { createUpdateSheet } from "./update_sheet.js";
|
|
103
107
|
import { createRename } from "./session_rename.js";
|
|
104
108
|
import { createSendTurn } from "./send_turn.js";
|
|
105
109
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
@@ -442,6 +446,8 @@ function openSessionEvents() {
|
|
|
442
446
|
eventsControl = openEvents({
|
|
443
447
|
snapshot: (data) => {
|
|
444
448
|
noticeVersions(data);
|
|
449
|
+
if (updateSheet.reconnected(data.version)) showToast(`chi web reconnected on chi ${data.version}`);
|
|
450
|
+
if (data.chi_update) updateFrame(data.chi_update, { snapshot: true });
|
|
445
451
|
notifications.noticeSessions("snapshot", data);
|
|
446
452
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
447
453
|
lists.render();
|
|
@@ -469,6 +475,7 @@ function openSessionEvents() {
|
|
|
469
475
|
leaveGoneSession(data.id, "deleted");
|
|
470
476
|
},
|
|
471
477
|
chi: (data) => noticeVersions(data),
|
|
478
|
+
chi_update: (data) => updateFrame(data),
|
|
472
479
|
}, {
|
|
473
480
|
dir: scope.dir,
|
|
474
481
|
onError: () => {
|
|
@@ -489,25 +496,75 @@ function openSessionEvents() {
|
|
|
489
496
|
|
|
490
497
|
const EVENTS_REOPEN_MS = 3000;
|
|
491
498
|
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// never forcing it) or when a newer chi is installed than chi web runs.
|
|
499
|
+
// The chi versions carried by each event snapshot and `chi` frame. A toast
|
|
500
|
+
// says when chi web was upgraded, a newer one is installed, or an aged one
|
|
501
|
+
// is available to update to.
|
|
496
502
|
// Once per notice per tab: every reconnect's snapshot says it again.
|
|
497
503
|
const versions = { served: null, installed: null };
|
|
498
504
|
// The sessions whose Restart is under way (their button says so): one
|
|
499
505
|
// session's restart never blocks another's.
|
|
500
506
|
const restartingIds = new Set();
|
|
507
|
+
// An available release's notice is once per version per browser, not per
|
|
508
|
+
// tab: chi web says it in every snapshot until the owner updates.
|
|
509
|
+
const AVAILABLE_SHOWN_KEY = "chi_update_available_shown";
|
|
501
510
|
const versionNoticesShown = new Set();
|
|
511
|
+
const availableShown = readStored(AVAILABLE_SHOWN_KEY);
|
|
512
|
+
if (availableShown) versionNoticesShown.add(`available:${availableShown}`);
|
|
513
|
+
let versionToast = null;
|
|
514
|
+
|
|
502
515
|
function noticeVersions(data) {
|
|
503
|
-
|
|
504
|
-
versions.installed = typeof data.installed === "string" ? data.installed : null;
|
|
516
|
+
Object.assign(versions, mergeVersionState(versions, data));
|
|
505
517
|
const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
|
|
506
|
-
installed: versions.installed,
|
|
518
|
+
installed: versions.installed, available: versions.available,
|
|
519
|
+
updateBlocked: versions.updateBlocked, shown: versionNoticesShown });
|
|
507
520
|
updateInfoBar(); // the worker badge compares with the newest installed
|
|
508
521
|
if (!notice) return;
|
|
509
522
|
versionNoticesShown.add(notice.key);
|
|
510
|
-
|
|
523
|
+
if (notice.key.startsWith("available:")) writeStored(AVAILABLE_SHOWN_KEY, notice.key.slice("available:".length));
|
|
524
|
+
const text = notice.releaseUrl
|
|
525
|
+
? html`${notice.text} <a href=${notice.releaseUrl} target="_blank" rel="noopener noreferrer">RubyGems</a>`
|
|
526
|
+
: notice.text;
|
|
527
|
+
const action = notice.reload
|
|
528
|
+
? { label: "Reload", run: () => location.reload() }
|
|
529
|
+
: notice.updateVersion
|
|
530
|
+
? { label: "Update", run: () => updateSheet.install(notice.updateVersion) }
|
|
531
|
+
: notice.actions
|
|
532
|
+
? notice.actions.map((name) => ({
|
|
533
|
+
label: name === "restartWorkers" ? "Restart workers" : "Restart chi web",
|
|
534
|
+
run: () => (name === "restartWorkers" ? updateSheet.restartWorkers() : updateSheet.restartWeb()),
|
|
535
|
+
}))
|
|
536
|
+
: notice.command
|
|
537
|
+
? { label: "Copy", run: () => copyUpdateCommand(notice.command) }
|
|
538
|
+
: null;
|
|
539
|
+
// One version toast at a time: a newer notice (Reload after a restart)
|
|
540
|
+
// supersedes the one before it.
|
|
541
|
+
toasts.dismiss(versionToast);
|
|
542
|
+
versionToast = toasts.show(text, action, { sticky: true });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function updateFrame(data, { snapshot = false } = {}) {
|
|
546
|
+
const wasOpen = updateSheet.isOpen();
|
|
547
|
+
if (!updateSheet.receive(data)) return;
|
|
548
|
+
if (data.operation === "install" && data.state === "done" && data.version) noticeVersions({ installed: data.version });
|
|
549
|
+
if (!wasOpen && data.state === "running") {
|
|
550
|
+
const label = data.operation === "install" ? "Chi update" : "Worker restart";
|
|
551
|
+
showToast(`${label} in progress`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
552
|
+
} else if (!snapshot && !wasOpen && ["done", "failed"].includes(data.state)
|
|
553
|
+
&& !(data.operation === "install" && data.state === "done")) {
|
|
554
|
+
const label = data.operation === "install" ? "Chi update" : "Worker restart";
|
|
555
|
+
showToast(`${label} ${data.state}`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function copyUpdateCommand(command) {
|
|
560
|
+
if (!navigator.clipboard?.writeText) {
|
|
561
|
+
showToast(`Clipboard unavailable; run ${command}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
navigator.clipboard.writeText(command).then(
|
|
565
|
+
() => showToast(`Copied ${command}`),
|
|
566
|
+
() => showToast(`Couldn't copy ${command}`),
|
|
567
|
+
);
|
|
511
568
|
}
|
|
512
569
|
|
|
513
570
|
// The selected session's card changed: its owner and, without a live
|
|
@@ -1754,6 +1811,12 @@ function restoreDraft(draft, opener) {
|
|
|
1754
1811
|
}
|
|
1755
1812
|
|
|
1756
1813
|
const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
|
|
1814
|
+
const updateSheet = createUpdateSheet({
|
|
1815
|
+
dialog: $("#updateSheet"),
|
|
1816
|
+
update: updateChi,
|
|
1817
|
+
restartWorkers,
|
|
1818
|
+
restartWeb,
|
|
1819
|
+
});
|
|
1757
1820
|
|
|
1758
1821
|
function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
|
|
1759
1822
|
|