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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +19 -1
- data/docs/cli.md +20 -5
- data/docs/desktop.md +51 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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/run_options.rb +43 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/update_command.rb +40 -13
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +198 -2
- data/lib/samagotchi/web/public/app.js +40 -1
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/update_notice.js +6 -6
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/server.rb +116 -7
- data/lib/samagotchi/web/session_hub.rb +28 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +14 -7
- metadata +3 -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
49
|
-
// - available newer than served:
|
|
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
|
-
|
|
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})
|
|
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
|
+
}
|