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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/docs/architecture.md +33 -7
  4. data/docs/cli.md +5 -1
  5. data/docs/configuration.md +57 -4
  6. data/docs/internals/background-tasks.md +2 -2
  7. data/docs/internals/engine.md +2 -1
  8. data/docs/internals/tool-guardrails.md +1 -1
  9. data/docs/internals/web-push.md +20 -0
  10. data/docs/sessions.md +2 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/config.rb +4 -0
  13. data/lib/samagotchi/engine.rb +4 -2
  14. data/lib/samagotchi/engine_peers.rb +2 -1
  15. data/lib/samagotchi/reply_wait/poll.rb +9 -2
  16. data/lib/samagotchi/reply_wait.rb +9 -3
  17. data/lib/samagotchi/task_finish_note.rb +8 -5
  18. data/lib/samagotchi/task_watch.rb +11 -6
  19. data/lib/samagotchi/tool_activity.rb +3 -3
  20. data/lib/samagotchi/tool_declarations.rb +2 -2
  21. data/lib/samagotchi/tools/builtins.rb +2 -1
  22. data/lib/samagotchi/tools/delegate_wait.rb +23 -2
  23. data/lib/samagotchi/tools/execute.rb +51 -9
  24. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +30 -158
  27. data/lib/samagotchi/web/auto_update.rb +282 -0
  28. data/lib/samagotchi/web/public/app.js +3 -3
  29. data/lib/samagotchi/web/public/task_stop.js +3 -3
  30. data/lib/samagotchi/web/public/update_notice.js +10 -4
  31. data/lib/samagotchi/web/public/update_sheet.js +3 -2
  32. data/lib/samagotchi/web/push/notifier.rb +66 -5
  33. data/lib/samagotchi/web/server.rb +45 -1
  34. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  35. data/lib/samagotchi/web/session_hub.rb +20 -3
  36. data/lib/samagotchi/web/update_check.rb +56 -10
  37. data/lib/samagotchi/web/update_ops.rb +240 -0
  38. data/lib/samagotchi/worker_task_wakes.rb +1 -1
  39. 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 and
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
- : `chi ${available} is available (you run ${served}).`,
77
- ...(blocked ? { command } : { updateVersion: available }),
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
- if (model.operation === "install") return `Update chi${model.version ? ` to ${model.version}` : ""}`;
29
- if (model.operation === "workers") return "Restart workers";
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? && !@updates_seen[version]
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[version] = true
204
- attention = Attention::Event.new(reason: "update", session_id: nil, key: "chi:update:#{version}", hold: nil)
205
- @queue << Show.new(attention: attention, title: "chi #{version} is available",
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)