samagotchi 0.138.0 → 0.139.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/docs/architecture.md +33 -7
- data/docs/cli.md +5 -1
- data/docs/configuration.md +57 -4
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/engine.md +2 -1
- data/docs/internals/tool-guardrails.md +1 -1
- data/docs/internals/web-push.md +20 -0
- data/docs/sessions.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +4 -2
- data/lib/samagotchi/engine_peers.rb +2 -1
- data/lib/samagotchi/reply_wait/poll.rb +9 -2
- data/lib/samagotchi/reply_wait.rb +9 -3
- data/lib/samagotchi/task_finish_note.rb +8 -5
- data/lib/samagotchi/task_watch.rb +11 -6
- data/lib/samagotchi/tool_activity.rb +3 -3
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +23 -2
- data/lib/samagotchi/tools/execute.rb +51 -9
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -158
- data/lib/samagotchi/web/auto_update.rb +282 -0
- data/lib/samagotchi/web/public/app.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -3
- data/lib/samagotchi/web/public/update_notice.js +10 -4
- data/lib/samagotchi/web/public/update_sheet.js +3 -2
- data/lib/samagotchi/web/push/notifier.rb +66 -5
- data/lib/samagotchi/web/server.rb +45 -1
- data/lib/samagotchi/web/session_access_routes.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +20 -3
- data/lib/samagotchi/web/update_check.rb +56 -10
- data/lib/samagotchi/web/update_ops.rb +240 -0
- data/lib/samagotchi/worker_task_wakes.rb +1 -1
- metadata +3 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
require_relative "../version"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Web
|
|
11
|
+
# With update.auto: install, installs the release UpdateCheck found when
|
|
12
|
+
# no turn runs anywhere, and tells the owner's phone how it went. Its own
|
|
13
|
+
# thread beside the checker; every POLL it takes the next step, so what
|
|
14
|
+
# stops it (a turn, another operation) only delays it.
|
|
15
|
+
#
|
|
16
|
+
# The gate: the install isn't blocked (a checkout, Bundler, an unwritable
|
|
17
|
+
# gem dir), no update operation runs, no session's turn runs or waits on
|
|
18
|
+
# a question (`chi update` merges shipped bundles that live workers
|
|
19
|
+
# read), and the release isn't one this already failed on (one automatic
|
|
20
|
+
# try per release: the Update button or a newer release tries again).
|
|
21
|
+
#
|
|
22
|
+
# With update.restart: idle (the default) the install is followed by:
|
|
23
|
+
# 1. the stale workers, only those idle and quiet for QUIET seconds (the
|
|
24
|
+
# worker still refuses atomically when it isn't), the left ones tried
|
|
25
|
+
# again each step for RETRY_FOR;
|
|
26
|
+
# 2. chi web itself, once no operation and no turn runs: a preflight
|
|
27
|
+
# (the newest chi must start and say its version) first, then the
|
|
28
|
+
# exec. The restarted chi web's own AutoUpdate sees phase `web`,
|
|
29
|
+
# counts the workers once more and says "chi updated": the process
|
|
30
|
+
# that survives is the one that speaks, and that proves it is up.
|
|
31
|
+
# A preflight that fails leaves chi web as it is and says so.
|
|
32
|
+
# update.restart: never stops after the install.
|
|
33
|
+
#
|
|
34
|
+
# The run's record lives in update_check.json (UpdateCheck#save_auto),
|
|
35
|
+
# so a chi web restart finds it. See docs/architecture.md.
|
|
36
|
+
class AutoUpdate
|
|
37
|
+
# Seconds between steps, and before the first (the checker's first
|
|
38
|
+
# check is at 30).
|
|
39
|
+
POLL = 300
|
|
40
|
+
FIRST = 60
|
|
41
|
+
# The first step of a chi web restarted by this run (it only has to finish).
|
|
42
|
+
FIRST_RESTARTED = 5
|
|
43
|
+
# A worker restarts unattended only when its session was idle and quiet
|
|
44
|
+
# this long (someone may be about to type), by its updated_at.
|
|
45
|
+
QUIET = 600
|
|
46
|
+
# The workers a run couldn't restart are tried again this long.
|
|
47
|
+
RETRY_FOR = 24 * 3600
|
|
48
|
+
# The phases a run is still in.
|
|
49
|
+
OPEN_PHASES = %w[installed workers web].freeze
|
|
50
|
+
|
|
51
|
+
# @param hub [SessionHub]
|
|
52
|
+
# @param ops [UpdateOps]
|
|
53
|
+
# @param check [UpdateCheck] the answer and the record
|
|
54
|
+
# @param auto [String] update.auto
|
|
55
|
+
# @param restart [String] update.restart (idle or never)
|
|
56
|
+
# @param preflight [#call, nil] `(version) → nil | String`: whether the
|
|
57
|
+
# newest chi starts (nil result), or why not (nil: no check)
|
|
58
|
+
# @param clock [#call] the Time now
|
|
59
|
+
# @param poll [Numeric] seconds between steps
|
|
60
|
+
def initialize(hub:, ops:, check:, auto: Config.get("update.auto"), restart: Config.get("update.restart"),
|
|
61
|
+
preflight: nil, clock: -> { Time.now }, poll: POLL, first: FIRST)
|
|
62
|
+
@hub = hub
|
|
63
|
+
@ops = ops
|
|
64
|
+
@check = check
|
|
65
|
+
@auto = auto
|
|
66
|
+
@restart = restart
|
|
67
|
+
@preflight = preflight
|
|
68
|
+
@clock = clock
|
|
69
|
+
@poll = poll
|
|
70
|
+
@first = first
|
|
71
|
+
@wakeup = Queue.new
|
|
72
|
+
@thread = nil
|
|
73
|
+
@stopped = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def start
|
|
77
|
+
return self unless @auto == "install"
|
|
78
|
+
return self if @thread&.alive?
|
|
79
|
+
|
|
80
|
+
@stopped = false
|
|
81
|
+
@thread = Thread.new { run_loop }
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop
|
|
86
|
+
@stopped = true
|
|
87
|
+
@wakeup << :stop
|
|
88
|
+
thread = @thread
|
|
89
|
+
@thread = nil
|
|
90
|
+
thread&.join(5.0)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One look at what to do next (the thread's step; specs call it).
|
|
95
|
+
def step
|
|
96
|
+
return unless @auto == "install"
|
|
97
|
+
|
|
98
|
+
record = @check.auto_state
|
|
99
|
+
return carry_on(record) if record && OPEN_PHASES.include?(record[:phase])
|
|
100
|
+
|
|
101
|
+
version = @check.available
|
|
102
|
+
return unless version
|
|
103
|
+
return if @check.auto_failed?(version) || tried?(version)
|
|
104
|
+
return if @ops.blocked_reason || @ops.running? || busy_turn?
|
|
105
|
+
|
|
106
|
+
install(version)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def run_loop
|
|
112
|
+
wait = restarted? ? FIRST_RESTARTED : @first
|
|
113
|
+
until @stopped
|
|
114
|
+
break if @wakeup.pop(timeout: wait) == :stop
|
|
115
|
+
|
|
116
|
+
step
|
|
117
|
+
wait = @poll
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
Log.exception(:web, "auto_update_failed", e)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A run that this chi web's start finds on its way to the web restart.
|
|
124
|
+
def restarted?
|
|
125
|
+
@check.auto_state&.dig(:phase) == "web"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# This release's run is already on record (installed, or given up on).
|
|
129
|
+
def tried?(version)
|
|
130
|
+
@check.auto_state&.dig(:version) == version
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A turn runs, or a question waits for its answer, in any session.
|
|
134
|
+
def busy_turn?
|
|
135
|
+
@hub.snapshot.any? do |summary|
|
|
136
|
+
summary[:status] == "running" || !summary[:pending_question].nil?
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def install(version)
|
|
141
|
+
Log.info(:web, "auto_update_start", version: version, from: VERSION)
|
|
142
|
+
result = @ops.install(version, wait: true, auto: true)
|
|
143
|
+
return Log.info(:web, "auto_update_deferred", version: version, why: result) if result.is_a?(Symbol)
|
|
144
|
+
|
|
145
|
+
rows = result[:rows]
|
|
146
|
+
record = { version: version, from: VERSION, at: @clock.call.iso8601, kept: kept_files(rows) }
|
|
147
|
+
if result[:state] == "done"
|
|
148
|
+
Log.info(:web, "auto_update_installed", version: version, kept: record[:kept].size)
|
|
149
|
+
installed(record)
|
|
150
|
+
else
|
|
151
|
+
failed(record, rows)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Installed. With restart: never the phone is told and the owner's
|
|
156
|
+
# toast and idle exit carry chi web and the workers over; else the
|
|
157
|
+
# restarts follow, here and now when nothing holds them.
|
|
158
|
+
def installed(record)
|
|
159
|
+
return carry_on(save(record.merge(phase: "installed"))) unless @restart == "never"
|
|
160
|
+
|
|
161
|
+
@hub.publish_notice(kind: "installed", version: record[:version], kept: record[:kept].size)
|
|
162
|
+
save(record.merge(phase: "done", pushed: true))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The next step of a run on record.
|
|
166
|
+
def carry_on(record)
|
|
167
|
+
case record[:phase]
|
|
168
|
+
when "installed", "workers" then workers_then_web(record)
|
|
169
|
+
when "web" then finish(record)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def workers_then_web(record)
|
|
174
|
+
return if @ops.running?
|
|
175
|
+
|
|
176
|
+
record = restart_workers(record) if record[:phase] == "installed" || workers_left?(record)
|
|
177
|
+
return unless record
|
|
178
|
+
|
|
179
|
+
restart_web(record)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def workers_left?(record)
|
|
183
|
+
record.dig(:workers, :left).to_i.positive? && @clock.call - recorded_at(record) < RETRY_FOR
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def recorded_at(record)
|
|
187
|
+
Time.iso8601(record[:at].to_s)
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
@clock.call
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A pass over the stale workers that are idle and quiet; the counts
|
|
193
|
+
# add up over passes. nil when another operation took the turn.
|
|
194
|
+
def restart_workers(record)
|
|
195
|
+
result = @ops.restart_workers(wait: true, auto: true, select: quiet_filter)
|
|
196
|
+
return if result.is_a?(Symbol)
|
|
197
|
+
|
|
198
|
+
states = result[:rows].map { |row| row["state"] }
|
|
199
|
+
restarted = record.dig(:workers, :restarted).to_i + states.count("restarted")
|
|
200
|
+
save(record.merge(phase: "workers", workers: { restarted: restarted, left: states.size - states.count("restarted") }))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Idle and quiet: no turn, no question, untouched for QUIET seconds.
|
|
204
|
+
def quiet_filter
|
|
205
|
+
summaries = @hub.snapshot.to_h { |summary| [summary[:id], summary] }
|
|
206
|
+
now = @clock.call
|
|
207
|
+
lambda do |worker|
|
|
208
|
+
summary = summaries[worker.session_id]
|
|
209
|
+
next false unless summary && summary[:status] == "idle" && summary[:pending_question].nil?
|
|
210
|
+
|
|
211
|
+
touched = parse_time(summary[:updated_at])
|
|
212
|
+
!touched.nil? && now - touched >= QUIET
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_time(value)
|
|
217
|
+
Time.iso8601(value.to_s)
|
|
218
|
+
rescue ArgumentError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# chi web itself, once no turn runs anywhere and the newest chi is
|
|
223
|
+
# known to start. The record says `web` before the exec: the process
|
|
224
|
+
# that comes up finishes the run.
|
|
225
|
+
def restart_web(record)
|
|
226
|
+
return if busy_turn?
|
|
227
|
+
|
|
228
|
+
problem = @preflight&.call(record[:version])
|
|
229
|
+
return restart_failed(record, problem, "auto_restart_preflight_failed") if problem
|
|
230
|
+
|
|
231
|
+
save(record.merge(phase: "web"))
|
|
232
|
+
Log.info(:web, "auto_update_restart", version: record[:version])
|
|
233
|
+
case (answer = @ops.restart_web)
|
|
234
|
+
when :started then nil
|
|
235
|
+
when :busy then save(record.merge(phase: "workers"))
|
|
236
|
+
else restart_failed(record, "chi web can't restart itself here (#{answer})", "auto_restart_refused")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def restart_failed(record, detail, event)
|
|
241
|
+
Log.warn(:web, event, version: record[:version], detail: detail)
|
|
242
|
+
@hub.publish_notice(kind: "restart_failed", version: record[:version], detail: detail)
|
|
243
|
+
save(record.merge(phase: "failed", detail: detail, pushed: true))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# In the restarted chi web: on the version, the workers counted once
|
|
247
|
+
# more, "chi updated" said.
|
|
248
|
+
def finish(record)
|
|
249
|
+
if Gem::Version.new(VERSION) < Gem::Version.new(record[:version])
|
|
250
|
+
return restart_failed(record, "chi web came back on #{VERSION}", "auto_restart_wrong_version")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
@hub.scan
|
|
254
|
+
record = restart_workers(record) || record
|
|
255
|
+
workers = record[:workers] || {}
|
|
256
|
+
Log.info(:web, "auto_update_done", version: record[:version], restarted: workers[:restarted], left: workers[:left])
|
|
257
|
+
@hub.publish_notice(kind: "updated", version: record[:version], kept: record[:kept].to_a.size,
|
|
258
|
+
restarted: workers[:restarted].to_i, left: workers[:left].to_i)
|
|
259
|
+
save(record.merge(phase: "done", pushed: true))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def save(record)
|
|
263
|
+
@check.save_auto(record)
|
|
264
|
+
record
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def failed(record, rows)
|
|
268
|
+
row = rows.find { |candidate| candidate["failed"] } || rows.first || {}
|
|
269
|
+
detail = row["status"].to_s.lines.first.to_s.strip
|
|
270
|
+
Log.warn(:web, "auto_update_failed_install", version: record[:version], detail: detail)
|
|
271
|
+
gem_installed = rows.any? { |candidate| candidate["component"] == "chi (gem)" && !candidate["failed"] }
|
|
272
|
+
@hub.publish_notice(kind: "failed", version: record[:version], detail: detail.empty? ? nil : detail,
|
|
273
|
+
installed: gem_installed)
|
|
274
|
+
@check.save_auto(record.merge(phase: "failed", detail: detail, pushed: true), failed: record[:version])
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def kept_files(rows)
|
|
278
|
+
rows.flat_map { |row| Array(row["kept"]).map(&:to_s) }.uniq
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -578,7 +578,7 @@ function noticeVersions(data) {
|
|
|
578
578
|
Object.assign(versions, mergeVersionState(versions, data));
|
|
579
579
|
const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
|
|
580
580
|
installed: versions.installed, available: versions.available,
|
|
581
|
-
updateBlocked: versions.updateBlocked, shown: versionNoticesShown });
|
|
581
|
+
updateBlocked: versions.updateBlocked, auto: versions.auto, shown: versionNoticesShown });
|
|
582
582
|
store.touch("infoBar"); // the worker badge compares with the newest installed
|
|
583
583
|
if (!notice) return;
|
|
584
584
|
versionNoticesShown.add(notice.key);
|
|
@@ -608,12 +608,12 @@ function updateFrame(data, { snapshot = false } = {}) {
|
|
|
608
608
|
const wasOpen = updateSheet.isOpen();
|
|
609
609
|
if (!updateSheet.receive(data)) return;
|
|
610
610
|
if (data.operation === "install" && data.state === "done" && data.version) noticeVersions({ installed: data.version });
|
|
611
|
+
const label = `${data.auto ? "Automatic " : ""}${data.operation === "install" ? "chi update" : "worker restart"}`
|
|
612
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
611
613
|
if (!wasOpen && data.state === "running") {
|
|
612
|
-
const label = data.operation === "install" ? "Chi update" : "Worker restart";
|
|
613
614
|
showToast(`${label} in progress`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
614
615
|
} else if (!snapshot && !wasOpen && ["done", "failed"].includes(data.state)
|
|
615
616
|
&& !(data.operation === "install" && data.state === "done")) {
|
|
616
|
-
const label = data.operation === "install" ? "Chi update" : "Worker restart";
|
|
617
617
|
showToast(`${label} ${data.state}`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
618
618
|
}
|
|
619
619
|
}
|
|
@@ -23,12 +23,12 @@ export function stopTarget(row, features) {
|
|
|
23
23
|
// A task_wait row's title without its wait ("npm test · up to 600s").
|
|
24
24
|
const WAIT_SUFFIX = / · up to \d+s$/;
|
|
25
25
|
|
|
26
|
-
// The first line of an execute that was still running at its limit
|
|
27
|
-
// went on as a task (Tools::Execute::HANDED_OVER), then its task_id line.
|
|
26
|
+
// The first line of an execute that was still running at its limit (or when
|
|
27
|
+
// a message from the user waited: "4s mark") and went on as a task (Tools::Execute::HANDED_OVER), then its task_id line.
|
|
28
28
|
// Anchored at the start, an optional "ran as: …" line and "[execute]" lead
|
|
29
29
|
// before it: a "task_id:" line anywhere else is the command's own output.
|
|
30
30
|
// The twin of TaskRuntime.handed_over_id.
|
|
31
|
-
const HANDED_OVER = /^(?:ran as: [^\n]*\n)?(?:\[execute\]\n)?Still running at the \d+s limit:[^\n]*\ntask_id: (\S+)/;
|
|
31
|
+
const HANDED_OVER = /^(?:ran as: [^\n]*\n)?(?:\[execute\]\n)?Still running at the \d+s (?:limit|mark):[^\n]*\ntask_id: (\S+)/;
|
|
32
32
|
|
|
33
33
|
// The task id an execute row's output hands over, else null.
|
|
34
34
|
export function handedOverId(output) {
|
|
@@ -15,6 +15,7 @@ export function mergeVersionState(current = {}, frame = {}) {
|
|
|
15
15
|
if (has(frame, "installed")) next.installed = known(frame.installed) ? frame.installed : null;
|
|
16
16
|
if (has(frame, "available")) next.available = known(frame.available) ? frame.available : null;
|
|
17
17
|
if (has(frame, "update_blocked")) next.updateBlocked = known(frame.update_blocked) ? frame.update_blocked : null;
|
|
18
|
+
if (has(frame, "auto")) next.auto = frame.auto === "install" ? "install" : "off";
|
|
18
19
|
return next;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -46,11 +47,13 @@ export function newerVersion(a, b) {
|
|
|
46
47
|
// - loaded ≠ served: chi web restarted on another version; reload the page;
|
|
47
48
|
// - installed newer than served: chi web runs an older chi than installed;
|
|
48
49
|
// restart its workers or chi web;
|
|
49
|
-
// - available newer than served: install it, unless the install is blocked
|
|
50
|
+
// - available newer than served: install it, unless the install is blocked;
|
|
51
|
+
// with update.auto: install (and not blocked) chi web installs it by itself
|
|
52
|
+
// once no turn runs: the notice says so, with no Update button.
|
|
50
53
|
// Each has a key; one in +shown+ (a Set: what this tab already showed, as
|
|
51
54
|
// every reconnect's snapshot says it again) is not shown twice.
|
|
52
55
|
// @return {{key, text, reload: boolean} | null}
|
|
53
|
-
export function versionNotice({ loaded, served, installed, available, updateBlocked, shown = new Set() } = {}) {
|
|
56
|
+
export function versionNotice({ loaded, served, installed, available, updateBlocked, auto, shown = new Set() } = {}) {
|
|
54
57
|
let notice = null;
|
|
55
58
|
if (known(loaded) && known(served) && loaded !== served) {
|
|
56
59
|
notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
|
|
@@ -68,13 +71,16 @@ export function versionNotice({ loaded, served, installed, available, updateBloc
|
|
|
68
71
|
gem_dir_not_writable: ["the RubyGems directory is not writable", `gem install samagotchi -v ${available}`],
|
|
69
72
|
};
|
|
70
73
|
const blocked = commands[updateBlocked];
|
|
74
|
+
const automatic = auto === "install" && !blocked;
|
|
71
75
|
const command = blocked?.[1] || "chi update";
|
|
72
76
|
notice = {
|
|
73
77
|
key: `available:${available}`,
|
|
74
78
|
text: blocked
|
|
75
79
|
? `chi ${available} is available (you run ${served}); ${blocked[0]}. Run ${command}.`
|
|
76
|
-
:
|
|
77
|
-
|
|
80
|
+
: automatic
|
|
81
|
+
? `chi ${available} is available (you run ${served}); it installs by itself once no turn is running.`
|
|
82
|
+
: `chi ${available} is available (you run ${served}).`,
|
|
83
|
+
...(blocked ? { command } : automatic ? {} : { updateVersion: available }),
|
|
78
84
|
releaseUrl: `https://rubygems.org/gems/samagotchi/versions/${encodeURIComponent(available)}`,
|
|
79
85
|
reload: false,
|
|
80
86
|
};
|
|
@@ -25,8 +25,9 @@ function workerRow(row) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function operationTitle(model) {
|
|
28
|
-
|
|
29
|
-
if (model.operation === "
|
|
28
|
+
const automatic = model.auto ? " (automatic)" : "";
|
|
29
|
+
if (model.operation === "install") return `Update chi${model.version ? ` to ${model.version}` : ""}${automatic}`;
|
|
30
|
+
if (model.operation === "workers") return `Restart workers${automatic}`;
|
|
30
31
|
return "Restart chi web";
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -48,6 +48,46 @@ module Samagotchi
|
|
|
48
48
|
EVENT_TTL = 600
|
|
49
49
|
# A newly available chi is useful for a day while the phone is away.
|
|
50
50
|
UPDATE_TTL = 24 * 3600
|
|
51
|
+
# The kinds of AutoUpdate's notices: [key kind, title, body] from the version and the notice's notes.
|
|
52
|
+
AUTO_NOTICES = {
|
|
53
|
+
"installed" => lambda do |version, notes|
|
|
54
|
+
["installed", "chi #{version} is installed",
|
|
55
|
+
"chi web and its sessions switch over when you restart them.#{kept_words(notes[:kept])}"]
|
|
56
|
+
end,
|
|
57
|
+
"updated" => lambda do |version, notes|
|
|
58
|
+
["updated", "chi updated to #{version}", "#{restarted_words(notes[:restarted], notes[:left])}#{kept_words(notes[:kept])}"]
|
|
59
|
+
end,
|
|
60
|
+
"restart_failed" => lambda do |version, notes|
|
|
61
|
+
["restart_failed", "chi #{version} is installed; chi web didn't restart",
|
|
62
|
+
"#{notes[:detail] || "chi web didn't restart"}. Restart chi web in its terminal."]
|
|
63
|
+
end,
|
|
64
|
+
"failed" => lambda do |version, notes|
|
|
65
|
+
["update_failed", notes[:installed] ? "chi #{version} installed with a problem" : "chi #{version} didn't install",
|
|
66
|
+
"#{notes[:detail] || "chi update failed"}. Open chi web for details."]
|
|
67
|
+
end
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# "chi web and 3 sessions restarted; 1 restarts when idle."
|
|
71
|
+
def self.restarted_words(restarted, left)
|
|
72
|
+
sessions = restarted.to_i
|
|
73
|
+
words = if sessions.positive?
|
|
74
|
+
"chi web and #{sessions} #{sessions == 1 ? "session" : "sessions"} restarted"
|
|
75
|
+
else
|
|
76
|
+
"chi web restarted"
|
|
77
|
+
end
|
|
78
|
+
words += "; #{left} #{left == 1 ? "restarts" : "restart"} when idle" if left.to_i.positive?
|
|
79
|
+
"#{words}."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# " Kept your edits in 2 files." ("" for none).
|
|
83
|
+
def self.kept_words(count)
|
|
84
|
+
if count.to_i.positive?
|
|
85
|
+
" Kept your edits in #{count} #{count == 1 ? "file" : "files"}."
|
|
86
|
+
else
|
|
87
|
+
""
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
51
91
|
# A show carries +open+ only while its JSON stays this small: one
|
|
52
92
|
# aes128gcm record leaves 3993 bytes of plaintext under Web Push's
|
|
53
93
|
# 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
|
|
@@ -191,19 +231,40 @@ module Samagotchi
|
|
|
191
231
|
@queue << Close.new(keys: keys) unless keys.empty?
|
|
192
232
|
when "chi"
|
|
193
233
|
notice_update(event)
|
|
234
|
+
when "chi_notice"
|
|
235
|
+
notice_auto_update(event)
|
|
194
236
|
end
|
|
195
237
|
end
|
|
196
238
|
|
|
197
239
|
def notice_update(event)
|
|
198
240
|
return unless event.data[:update_notice]
|
|
241
|
+
# With update.auto: install (and an install that can run) the page says it will install on its own,
|
|
242
|
+
# and the result is what the phone hears: no "available" first.
|
|
243
|
+
return if event.data[:auto] == "install" && !event.data[:update_blocked]
|
|
199
244
|
|
|
200
245
|
version = event.data[:available]
|
|
201
|
-
return unless version.is_a?(String) && !version.empty?
|
|
246
|
+
return unless version.is_a?(String) && !version.empty?
|
|
247
|
+
|
|
248
|
+
enqueue_update("chi:update:#{version}", "chi #{version} is available", update_body(event.data[:update_blocked]))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# AutoUpdate's word (SessionHub#publish_notice): installed, updated, restart_failed or failed.
|
|
252
|
+
def notice_auto_update(event)
|
|
253
|
+
data = event.data
|
|
254
|
+
version = data[:version]
|
|
255
|
+
return unless version.is_a?(String) && !version.empty?
|
|
256
|
+
|
|
257
|
+
kind, title, body = AUTO_NOTICES.fetch(data[:kind], nil)&.call(version, data)
|
|
258
|
+
enqueue_update("chi:#{kind}:#{version}", title, body) if kind
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Once per key in this process (AutoUpdate keeps its own record across restarts).
|
|
262
|
+
def enqueue_update(key, title, body)
|
|
263
|
+
return if @updates_seen[key]
|
|
202
264
|
|
|
203
|
-
@updates_seen[
|
|
204
|
-
attention = Attention::Event.new(reason: "update", session_id: nil, key:
|
|
205
|
-
@queue << Show.new(attention: attention, title:
|
|
206
|
-
body: update_body(event.data[:update_blocked]), at: @now.call, url: "/")
|
|
265
|
+
@updates_seen[key] = true
|
|
266
|
+
attention = Attention::Event.new(reason: "update", session_id: nil, key: key, hold: nil)
|
|
267
|
+
@queue << Show.new(attention: attention, title: title, body: body, at: @now.call, url: "/")
|
|
207
268
|
end
|
|
208
269
|
|
|
209
270
|
# A blocked install (a checkout, Bundler, an unwritable gem dir) names its own command on the page.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "open3"
|
|
4
5
|
require "rack"
|
|
5
6
|
require "rackup/handler/webrick"
|
|
6
7
|
require "rbconfig"
|
|
8
|
+
require "timeout"
|
|
7
9
|
require "uri"
|
|
8
10
|
require "webrick"
|
|
9
11
|
|
|
@@ -19,6 +21,7 @@ require_relative "running"
|
|
|
19
21
|
require_relative "start_dir"
|
|
20
22
|
require_relative "session_hub"
|
|
21
23
|
require_relative "update_check"
|
|
24
|
+
require_relative "auto_update"
|
|
22
25
|
require_relative "token"
|
|
23
26
|
require_relative "tunnel"
|
|
24
27
|
require_relative "../config"
|
|
@@ -72,6 +75,8 @@ module Samagotchi
|
|
|
72
75
|
PROBE_TIMEOUT = 0.3
|
|
73
76
|
RESTARTED_ENV = "SAMAGOTCHI_WEB_RESTARTED"
|
|
74
77
|
ADOPT_PIDS_ENV = "SAMAGOTCHI_WEB_ADOPT_PIDS"
|
|
78
|
+
# How long AutoUpdate's pre-restart `chi --version` may take.
|
|
79
|
+
PREFLIGHT_TIMEOUT = 20
|
|
75
80
|
|
|
76
81
|
# The raw header names next to Rack's env (ProxyGate's identity check):
|
|
77
82
|
# chi web is the only WEBrick server chi runs.
|
|
@@ -254,6 +259,39 @@ module Samagotchi
|
|
|
254
259
|
program.to_s.empty? ? nil : [RbConfig.ruby, program, *argv]
|
|
255
260
|
end
|
|
256
261
|
|
|
262
|
+
# Before an unattended restart (AutoUpdate): the command a restart would
|
|
263
|
+
# exec, run with --version, must print a chi at least +version+, so a
|
|
264
|
+
# broken install leaves chi web up instead of dead.
|
|
265
|
+
# @param version [String] the release installed
|
|
266
|
+
# @param timeout [Numeric] seconds the command may take
|
|
267
|
+
# @param runner [#call, nil] `(command, env, timeout) → String` its output (a spec's seam)
|
|
268
|
+
# @return [String, nil] nil when it starts, else why not
|
|
269
|
+
def self.restart_preflight(version, timeout: PREFLIGHT_TIMEOUT, runner: nil)
|
|
270
|
+
command = reexec_command(["--version"])
|
|
271
|
+
return "there is no chi to restart into" unless command
|
|
272
|
+
|
|
273
|
+
output = (runner || method(:run_preflight)).call(command, ChildEnv.unbundled, timeout).to_s
|
|
274
|
+
said = output[/\bchi (\S+)/, 1]
|
|
275
|
+
return nil if said && Gem::Version.correct?(said) && Gem::Version.new(said) >= Gem::Version.new(version)
|
|
276
|
+
|
|
277
|
+
"the new chi said #{output.strip.lines.first.to_s.strip[0, 120].inspect}, not #{version}"
|
|
278
|
+
rescue StandardError => e
|
|
279
|
+
"the new chi didn't start (#{e.class}: #{e.message.lines.first.to_s.strip[0, 120]})"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The preflight's command, killed when it takes longer than +timeout+.
|
|
283
|
+
def self.run_preflight(command, env, timeout)
|
|
284
|
+
Open3.popen2e(env, *command) do |stdin, out, wait_thread|
|
|
285
|
+
stdin.close
|
|
286
|
+
reader = Thread.new { out.read }
|
|
287
|
+
unless wait_thread.join(timeout)
|
|
288
|
+
Process.kill("KILL", wait_thread.pid)
|
|
289
|
+
raise Timeout::Error, "took more than #{timeout} s"
|
|
290
|
+
end
|
|
291
|
+
reader.value
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
257
295
|
# What a restart execs without a chi home: the installed gem's wrapper
|
|
258
296
|
# (it activates the newest installed chi), this bin/chi in a checkout;
|
|
259
297
|
# nil for a gem installed without one (--bindir).
|
|
@@ -342,13 +380,15 @@ module Samagotchi
|
|
|
342
380
|
# @param on_bound [#call, nil] run once the port is bound, before any
|
|
343
381
|
# line (the tunnel's #up); what it raises ends the run
|
|
344
382
|
# @param start_dir [String] the folder chi web starts in (App's)
|
|
383
|
+
# @param update_check [UpdateCheck, nil] the RubyGems checker (nil: built from config)
|
|
384
|
+
# @param auto_update [AutoUpdate, nil] update.auto: install's thread (nil: built from config)
|
|
345
385
|
# @return [Boolean, :restart] false when the port was taken (said so on stderr);
|
|
346
386
|
# :restart when the owner requested an in-place process replacement
|
|
347
387
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
348
388
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
349
389
|
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
|
|
350
390
|
working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [],
|
|
351
|
-
on_bound: nil, start_dir: Dir.pwd, update_check: nil)
|
|
391
|
+
on_bound: nil, start_dir: Dir.pwd, update_check: nil, auto_update: nil)
|
|
352
392
|
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
353
393
|
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
354
394
|
end
|
|
@@ -365,6 +405,8 @@ module Samagotchi
|
|
|
365
405
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown,
|
|
366
406
|
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
|
|
367
407
|
public_url: public_url, public_token: public_token, push: push, start_dir: start_dir)
|
|
408
|
+
# update.auto: install; a stand-in app (a spec's) has no operations to run.
|
|
409
|
+
auto_update ||= AutoUpdate.new(hub: hub, ops: app.update_ops, check: update_check, preflight: method(:restart_preflight)) if app.respond_to?(:update_ops)
|
|
368
410
|
webrick = nil
|
|
369
411
|
restart_mutex = Mutex.new
|
|
370
412
|
restart_requested = false
|
|
@@ -402,6 +444,7 @@ module Samagotchi
|
|
|
402
444
|
end
|
|
403
445
|
hub.start
|
|
404
446
|
update_check.start
|
|
447
|
+
auto_update&.start
|
|
405
448
|
# Said once the port is bound: a second chi web racing for it gets
|
|
406
449
|
# the in-use line instead.
|
|
407
450
|
running_path = Running.path(state_dir: state_dir)
|
|
@@ -458,6 +501,7 @@ module Samagotchi
|
|
|
458
501
|
Running.clear(path: running_path) if running_path
|
|
459
502
|
config_restart_unsubscribe&.call
|
|
460
503
|
notifier&.stop
|
|
504
|
+
auto_update&.stop
|
|
461
505
|
update_check&.stop
|
|
462
506
|
hub&.stop
|
|
463
507
|
Samagotchi::Log.info(:web, "stop") if app
|
|
@@ -274,7 +274,7 @@ module Samagotchi
|
|
|
274
274
|
model_name: session_access.show(id).model_name,
|
|
275
275
|
default_model: ModelProfile.default_model_name,
|
|
276
276
|
parent_strategy: strategy_json(session_access.parent_strategy(id)),
|
|
277
|
-
**(@hub&.chi_state || { version: VERSION, installed: nil, available: nil, update_blocked: nil }) })
|
|
277
|
+
**(@hub&.chi_state || { version: VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off" }) })
|
|
278
278
|
rescue SessionManager::OwnedByTUI => e
|
|
279
279
|
owned_by_tui(e)
|
|
280
280
|
rescue SessionAccess::Refused => e
|
|
@@ -8,6 +8,7 @@ require_relative "../session"
|
|
|
8
8
|
require_relative "../session_manager"
|
|
9
9
|
require_relative "../bridge_client"
|
|
10
10
|
require_relative "../worker_link"
|
|
11
|
+
require_relative "../config"
|
|
11
12
|
require_relative "../log"
|
|
12
13
|
require_relative "../installed_versions"
|
|
13
14
|
require_relative "../version"
|
|
@@ -73,9 +74,12 @@ module Samagotchi
|
|
|
73
74
|
# or a stand-in
|
|
74
75
|
# @param now [#call] a monotonic clock in seconds (specs drive it)
|
|
75
76
|
# @param installed_versions [#newest] what is installed (InstalledVersions)
|
|
77
|
+
# @param update_auto [String] update.auto, said in the chi state (the page's wording)
|
|
76
78
|
def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
|
|
77
|
-
full_probe_interval: FULL_PROBE_INTERVAL, installed_versions: InstalledVersions.new
|
|
79
|
+
full_probe_interval: FULL_PROBE_INTERVAL, installed_versions: InstalledVersions.new,
|
|
80
|
+
update_auto: Config.get("update.auto"))
|
|
78
81
|
@state_dir = state_dir
|
|
82
|
+
@update_auto = update_auto
|
|
79
83
|
@installed_versions = installed_versions
|
|
80
84
|
@installed = nil
|
|
81
85
|
@available = nil
|
|
@@ -156,7 +160,8 @@ module Samagotchi
|
|
|
156
160
|
# The complete chi version state used by event snapshots and info routes.
|
|
157
161
|
def chi_state
|
|
158
162
|
@monitor.synchronize do
|
|
159
|
-
{ version: VERSION, installed: @installed, available: @available, update_blocked: @update_blocked
|
|
163
|
+
{ version: VERSION, installed: @installed, available: @available, update_blocked: @update_blocked,
|
|
164
|
+
auto: @update_auto }
|
|
160
165
|
end
|
|
161
166
|
end
|
|
162
167
|
|
|
@@ -174,9 +179,10 @@ module Samagotchi
|
|
|
174
179
|
|
|
175
180
|
# Publish the owner-triggered update's progress or result for current
|
|
176
181
|
# tabs, and retain the latest state for a reconnect's snapshot.
|
|
177
|
-
def publish_update(state:, rows:, progress: nil, operation: nil, version: nil)
|
|
182
|
+
def publish_update(state:, rows:, progress: nil, operation: nil, version: nil, auto: false)
|
|
178
183
|
@monitor.synchronize do
|
|
179
184
|
@update_state = { state: state, rows: rows }
|
|
185
|
+
@update_state[:auto] = true if auto
|
|
180
186
|
@update_state[:operation] = operation unless operation.nil?
|
|
181
187
|
@update_state[:version] = version unless version.nil?
|
|
182
188
|
@update_state[:progress] = progress unless progress.nil?
|
|
@@ -189,6 +195,17 @@ module Samagotchi
|
|
|
189
195
|
@monitor.synchronize { @update_state&.dup }
|
|
190
196
|
end
|
|
191
197
|
|
|
198
|
+
# What the automatic update (AutoUpdate) has to tell the owner's phone:
|
|
199
|
+
# a `chi_notice` event for the Push::Notifier, not for the page (the
|
|
200
|
+
# `chi_update` frames are the page's), and not kept for a snapshot.
|
|
201
|
+
# @param kind [String] installed, updated or failed
|
|
202
|
+
# @param version [String] the release it is about
|
|
203
|
+
# @param notes [Hash] detail:, kept: (a count), restarted:, left:
|
|
204
|
+
def publish_notice(kind:, version:, **notes)
|
|
205
|
+
@monitor.synchronize { emit("chi_notice", { kind: kind, version: version, **notes }) }
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
192
209
|
# Rescan one session now (its file, its folder, its owner), for the
|
|
193
210
|
# page's own actions: no waiting for the next tick.
|
|
194
211
|
def touch(id)
|