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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -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). installed: the newest chi
869
- # on this machine (nil until the hub has looked); newest_workers: the
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
- json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
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
- @installed = @hub.installed
1996
+ @chi_state = @hub.chi_state
1803
1997
  end
1804
1998
 
1805
1999
  def each
1806
- # version: the page compares it with the one it was served by;
1807
- # installed: the newest chi on this machine (a later change is a
1808
- # `chi` event).
1809
- yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION, installed: @installed)
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, on_write: 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
- message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"])) if norm_role == "user"
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.of(session).to_s
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
- def author_fields(author, name, viewer)
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
- warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
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
- // Which chi serves this page now and which is the newest installed (the
493
- // events snapshot, and `chi` frames when that changes). A toast says when
494
- // chi web was upgraded while this tab stayed open (offering the reload,
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
- if (typeof data.version === "string") versions.served = data.version;
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, shown: versionNoticesShown });
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
- showToast(notice.text, notice.reload ? { label: "Reload", run: () => location.reload() } : null, { sticky: true });
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