samagotchi 0.123.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.
@@ -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)
@@ -1805,7 +1998,10 @@ module Samagotchi
1805
1998
 
1806
1999
  def each
1807
2000
  # The page compares this full chi state with the one it was served by.
1808
- yield frame(nil, "snapshot", { sessions: @snapshot }.merge(@chi_state))
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)
1809
2005
  last_write = monotonic
1810
2006
  loop do
1811
2007
  event = @queue.pop([@heartbeat, EVENTS_POLL].min)
@@ -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";
@@ -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: () => {
@@ -503,6 +510,8 @@ const AVAILABLE_SHOWN_KEY = "chi_update_available_shown";
503
510
  const versionNoticesShown = new Set();
504
511
  const availableShown = readStored(AVAILABLE_SHOWN_KEY);
505
512
  if (availableShown) versionNoticesShown.add(`available:${availableShown}`);
513
+ let versionToast = null;
514
+
506
515
  function noticeVersions(data) {
507
516
  Object.assign(versions, mergeVersionState(versions, data));
508
517
  const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
@@ -517,10 +526,34 @@ function noticeVersions(data) {
517
526
  : notice.text;
518
527
  const action = notice.reload
519
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
+ }))
520
536
  : notice.command
521
537
  ? { label: "Copy", run: () => copyUpdateCommand(notice.command) }
522
538
  : null;
523
- showToast(text, action, { sticky: true });
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
+ }
524
557
  }
525
558
 
526
559
  function copyUpdateCommand(command) {
@@ -1778,6 +1811,12 @@ function restoreDraft(draft, opener) {
1778
1811
  }
1779
1812
 
1780
1813
  const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
1814
+ const updateSheet = createUpdateSheet({
1815
+ dialog: $("#updateSheet"),
1816
+ update: updateChi,
1817
+ restartWorkers,
1818
+ restartWeb,
1819
+ });
1781
1820
 
1782
1821
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
1783
1822
 
@@ -88,6 +88,23 @@ export function listModels(opts = {}) {
88
88
  return api("/api/models", opts);
89
89
  }
90
90
 
91
+ // Update chi to this exact version; progress and component rows arrive as
92
+ // `chi_update` events on the page's events stream.
93
+ export function updateChi(version, opts = {}) {
94
+ return api("/api/update", { method: "POST", body: JSON.stringify({ version }), ...opts });
95
+ }
96
+
97
+ // Restart every worker running an older chi; results arrive as
98
+ // `chi_update` events on the page's events stream.
99
+ export function restartWorkers(opts = {}) {
100
+ return api("/api/update/restart_workers", { method: "POST", body: JSON.stringify({}), ...opts });
101
+ }
102
+
103
+ // Ask this chi web to shut down and exec the installed version.
104
+ export function restartWeb(opts = {}) {
105
+ return api("/api/update/restart_web", { method: "POST", body: JSON.stringify({}), ...opts });
106
+ }
107
+
91
108
  // The prompt history the composer's ↑/↓ walk (the TUI's too), oldest first.
92
109
  export function fetchHistory(opts = {}) {
93
110
  return api("/api/history", opts).then((j) => (Array.isArray(j.entries) ? j.entries : []));
@@ -1182,6 +1182,23 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1182
1182
  .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
1183
1183
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1184
1184
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1185
+ #updateSheet{width:calc(100vw - 32px);max-width:560px;max-height:calc(100dvh - 32px);overflow:auto;margin:auto;padding:14px 16px;border:1px solid var(--border-strong);border-radius:14px;background:var(--bg-panel);color:var(--text);box-shadow:var(--toast-shadow)}
1186
+ #updateSheet::backdrop{background:var(--backdrop);backdrop-filter:blur(3px)}
1187
+ .us-head{display:flex;align-items:center;gap:10px}
1188
+ .us-head h2{flex:1;min-width:0;font-size:16px;font-weight:650;color:var(--text-bright);overflow-wrap:anywhere}
1189
+ .us-close{flex:none;padding:0 6px;background:transparent;color:var(--text-muted);font-size:19px;line-height:1.2}
1190
+ .us-status{margin:8px 0;color:var(--text-2);font-size:13px;overflow-wrap:anywhere}
1191
+ .us-rows{display:flex;flex-direction:column;gap:7px;list-style:none;margin:10px 0;padding:0}
1192
+ .us-row{min-width:0;padding:9px 10px;border-radius:9px;background:var(--bg-inset);overflow-wrap:anywhere}
1193
+ .us-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
1194
+ .us-row-head strong{flex:none;max-width:100%;color:var(--text-bright);overflow-wrap:anywhere}
1195
+ .us-row-head span{flex:1 1 0;min-width:0;text-align:right;color:var(--text-2);font-size:12px;overflow-wrap:anywhere}
1196
+ .us-detail{margin-top:4px;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}
1197
+ .us-kept{margin-top:6px;color:var(--warn-text);font-size:12px}
1198
+ .us-kept ul{margin:3px 0 0;padding-left:18px;color:var(--text-2)}
1199
+ .us-kept li{overflow-wrap:anywhere}
1200
+ .us-foot{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
1201
+ @media(max-width:600px){#updateSheet{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(80dvh,calc(100dvh - 16px));margin:0}}
1185
1202
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
1186
1203
  .ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
1187
1204
  .ctx-pop-close:hover{color:var(--text)}
@@ -1448,8 +1465,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1448
1465
  /* bottom: above the session's composer card when one shows (--toast-bottom, dock_reserve.js placeToast). */
1449
1466
  #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1450
1467
  #toast[hidden]{display:none}
1451
- #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1452
- #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1468
+ #toast .toast-item{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1469
+ #toast .toast-message{flex:1 1 180px;min-width:0;overflow-wrap:anywhere}
1470
+ #toast .toast-action{flex:0 0 auto;white-space:nowrap;background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1453
1471
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1454
1472
  #toast .toast-close:hover{color:var(--text)}
1455
1473
  /* Each row is as tall as its tallest card. An `auto` row in a grid that overflows its
@@ -1608,6 +1626,7 @@ a{color:var(--accent)}
1608
1626
  <div id="allList"></div>
1609
1627
  </section>
1610
1628
  </main>
1629
+ <dialog id="updateSheet" aria-labelledby="updateSheetTitle"></dialog>
1611
1630
  <div id="toast" role="status" aria-live="polite" hidden></div>
1612
1631
  <script type="module" src="/assets/app.js"></script>
1613
1632
  </body>
@@ -30,9 +30,11 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
30
30
  }
31
31
 
32
32
  function draw(entry, text, action, sticky) {
33
- render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${action
34
- ? html`<button type="button" class="toast-action" @click=${() => { remove(entry); action.run(); }}>${action.label}</button>`
35
- : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>` : nothing}`,
33
+ const actions = Array.isArray(action) ? action : action ? [action] : [];
34
+ render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${actions.map((item) =>
35
+ html`<button type="button" class="toast-action" @click=${() => { remove(entry); item.run(); }}>${item.label}</button>`)}${sticky
36
+ ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>`
37
+ : nothing}`,
36
38
  entry.el);
37
39
  }
38
40
 
@@ -61,7 +63,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
61
63
  el.appendChild(duplicate.el);
62
64
  if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
63
65
  sync();
64
- return;
66
+ return duplicate;
65
67
  }
66
68
 
67
69
  if (entries.length === MAX_TOASTS) {
@@ -72,7 +74,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
72
74
  el.appendChild(entry.el);
73
75
  if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
74
76
  sync();
77
+ return entry;
78
+ }
79
+
80
+ // Takes down a toast show returned (gone already: nothing).
81
+ function dismiss(entry) {
82
+ if (entry) remove(entry);
75
83
  }
76
84
 
77
- return { show };
85
+ return { show, dismiss };
78
86
  }
@@ -45,8 +45,8 @@ export function newerVersion(a, b) {
45
45
  // The toast for the versions as the page knows them, or null:
46
46
  // - loaded ≠ served: chi web restarted on another version; reload the page;
47
47
  // - installed newer than served: chi web runs an older chi than installed;
48
- // only its terminal can restart it;
49
- // - available newer than served: run chi update, unless the install is blocked.
48
+ // restart its workers or chi web;
49
+ // - available newer than served: install it, unless the install is blocked.
50
50
  // Each has a key; one in +shown+ (a Set: what this tab already showed, as
51
51
  // every reconnect's snapshot says it again) is not shown twice.
52
52
  // @return {{key, text, reload: boolean} | null}
@@ -57,8 +57,8 @@ export function versionNotice({ loaded, served, installed, available, updateBloc
57
57
  } else if (newerVersion(installed, served)) {
58
58
  notice = {
59
59
  key: `installed:${installed}`,
60
- text: `chi ${installed} is installed; this chi web runs ${served}. ` +
61
- "Restart it: Ctrl-C in its terminal, then chi web",
60
+ text: `chi ${installed} is installed; this chi web runs ${served}.`,
61
+ actions: ["restartWorkers", "restartWeb"],
62
62
  reload: false,
63
63
  };
64
64
  } else if (newerVersion(available, served)) {
@@ -73,8 +73,8 @@ export function versionNotice({ loaded, served, installed, available, updateBloc
73
73
  key: `available:${available}`,
74
74
  text: blocked
75
75
  ? `chi ${available} is available (you run ${served}); ${blocked[0]}. Run ${command}.`
76
- : `chi ${available} is available (you run ${served}). Run ${command}.`,
77
- command,
76
+ : `chi ${available} is available (you run ${served}).`,
77
+ ...(blocked ? { command } : { updateVersion: available }),
78
78
  releaseUrl: `https://rubygems.org/gems/samagotchi/versions/${encodeURIComponent(available)}`,
79
79
  reload: false,
80
80
  };
@@ -0,0 +1,158 @@
1
+ import { html, nothing, render } from "./vendor/lit-html.js";
2
+
3
+ function installRow(row) {
4
+ const kept = Array.isArray(row.kept) ? row.kept.map(String) : [];
5
+ const versions = [row.from, row.to].filter(Boolean).join(" → ");
6
+ return html`<li class="us-row">
7
+ <div class="us-row-head"><strong>${row.component || "chi update"}</strong><span>${row.status || "working"}</span></div>
8
+ ${versions ? html`<div class="us-detail">${versions}</div>` : nothing}
9
+ ${kept.length ? html`<div class="us-kept"><strong>Kept edited files:</strong><ul>${kept.map((file) => html`<li>${file}</li>`)}</ul></div>` : nothing}
10
+ </li>`;
11
+ }
12
+
13
+ function workerRow(row) {
14
+ const sessionId = String(row.session_id || "unknown");
15
+ const detail = row.detail || row.reason;
16
+ const holds = Array.isArray(row.holds) ? row.holds.map((hold) => String(hold).replaceAll("_", " ")).join(", ") : "";
17
+ const version = row.version ? `chi ${row.version}` : "";
18
+ return html`<li class="us-row">
19
+ <div class="us-row-head"><strong title=${sessionId}>${sessionId.slice(0, 8)}</strong>
20
+ <span>${String(row.state || "working").replaceAll("_", " ")}</span></div>
21
+ ${version ? html`<div class="us-detail">${version}</div>` : nothing}
22
+ ${detail ? html`<div class="us-detail">${detail}</div>` : nothing}
23
+ ${holds ? html`<div class="us-detail">Holds: ${holds}</div>` : nothing}
24
+ </li>`;
25
+ }
26
+
27
+ function operationTitle(model) {
28
+ if (model.operation === "install") return `Update chi${model.version ? ` to ${model.version}` : ""}`;
29
+ if (model.operation === "workers") return "Restart workers";
30
+ return "Restart chi web";
31
+ }
32
+
33
+ // A running operation's last progress line; the end's own words after it
34
+ // (a done frame carries no progress, and the last line would stay).
35
+ function statusText(model) {
36
+ if (model.error) return model.error;
37
+ if (model.operation === "web" && model.state === "restarting") return "Waiting for chi web to reconnect…";
38
+ if (model.operation === "web" && model.state === "done") return `Reconnected${model.version ? ` on chi ${model.version}` : ""}.`;
39
+ if (model.state === "starting") return "Starting…";
40
+ if (model.state === "running") return model.progress || (model.operation === "install" ? "Installing…" : "Restarting…");
41
+ if (model.state === "done") return model.operation === "install" ? "Update complete." : "Worker restart results.";
42
+ return "Operation failed.";
43
+ }
44
+
45
+ // Both restarts, once a newer chi is installed (an install done here, or the
46
+ // installed notice's toast) and nothing runs: either result keeps the other in reach.
47
+ function restartActions(model) {
48
+ const busy = ["starting", "running", "restarting"].includes(model.state);
49
+ if (!model.restartable || busy) return nothing;
50
+ return html`<button type="button" class="ghost" data-act="restart-workers">Restart workers</button>
51
+ <button type="button" class="ghost" data-act="restart-web">Restart chi web</button>`;
52
+ }
53
+
54
+ export function updateSheetTemplate(model) {
55
+ const rows = Array.isArray(model.rows) ? model.rows : [];
56
+ const rowTemplate = model.operation === "workers" ? workerRow : installRow;
57
+ return html`<header class="us-head">
58
+ <h2 id="updateSheetTitle">${operationTitle(model)}</h2>
59
+ <button type="button" class="us-close" data-act="close" aria-label="Close">×</button>
60
+ </header>
61
+ <p class="us-status" aria-live="polite">${statusText(model)}</p>
62
+ ${rows.length ? html`<ol class="us-rows">${rows.map(rowTemplate)}</ol>` : nothing}
63
+ <footer class="us-foot">${restartActions(model)}<button type="button" class="ghost" data-act="close">Close</button></footer>`;
64
+ }
65
+
66
+ // The update progress/result sheet; operations continue if it is dismissed.
67
+ export function createUpdateSheet({ dialog, update, restartWorkers, restartWeb }) {
68
+ let model = null;
69
+ let requestId = 0;
70
+ let restartable = false;
71
+
72
+ function draw() {
73
+ if (model) render(updateSheetTemplate({ ...model, restartable }), dialog);
74
+ }
75
+
76
+ function show(next) {
77
+ model = { ...next, requestId: ++requestId };
78
+ draw();
79
+ if (!dialog.open) dialog.showModal();
80
+ }
81
+
82
+ function fail(error, expectedId) {
83
+ if (!model || model.requestId !== expectedId) return;
84
+ model = { ...model, state: "failed", error: error?.message || String(error) };
85
+ draw();
86
+ }
87
+
88
+ function request(action) {
89
+ const expectedId = model.requestId;
90
+ Promise.resolve().then(action).catch((error) => fail(error, expectedId));
91
+ }
92
+
93
+ dialog.addEventListener("click", (event) => {
94
+ const act = event.target.closest?.("[data-act]")?.dataset.act;
95
+ if (act === "close") dialog.close();
96
+ else if (act === "restart-workers") openWorkers();
97
+ else if (act === "restart-web") openWeb();
98
+ });
99
+
100
+ function openInstall(version) {
101
+ show({ operation: "install", version, state: "starting", rows: [] });
102
+ request(() => update(version));
103
+ }
104
+
105
+ function openWorkers() {
106
+ restartable = true;
107
+ show({ operation: "workers", state: "starting", rows: [] });
108
+ request(restartWorkers);
109
+ }
110
+
111
+ function openWeb() {
112
+ restartable = true;
113
+ show({ operation: "web", state: "restarting", rows: [] });
114
+ request(restartWeb);
115
+ }
116
+
117
+ function receive(data) {
118
+ if (!["install", "workers"].includes(data?.operation)) return false;
119
+ if (model?.operation === "web" && model.state === "restarting") return false;
120
+ if (!model || (model.operation !== data.operation && ["done", "failed"].includes(model.state))) {
121
+ model = { operation: data.operation, state: data.state, rows: [], version: data.version };
122
+ }
123
+ if (model.operation !== data.operation) return false;
124
+ model = {
125
+ ...model,
126
+ ...data,
127
+ rows: Array.isArray(data.rows) ? data.rows : [],
128
+ };
129
+ if (data.operation === "install" && data.state === "done") restartable = true;
130
+ draw();
131
+ return true;
132
+ }
133
+
134
+ function reconnected(version) {
135
+ if (model?.operation !== "web" || model.state !== "restarting") return false;
136
+ model = { ...model, state: "done", version };
137
+ draw();
138
+ return true;
139
+ }
140
+
141
+ function view() {
142
+ if (!model) return false;
143
+ draw();
144
+ if (!dialog.open) dialog.showModal();
145
+ return true;
146
+ }
147
+
148
+ return {
149
+ install: openInstall,
150
+ restartWorkers: openWorkers,
151
+ restartWeb: openWeb,
152
+ receive,
153
+ reconnected,
154
+ view,
155
+ close: () => dialog.close(),
156
+ isOpen: () => dialog.open,
157
+ };
158
+ }