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
@@ -17,13 +17,22 @@ module Samagotchi
17
17
  module Web
18
18
  # Checks RubyGems away from the session hub's Monitor and publishes the
19
19
  # result as chi web's version state. The state file preserves cooldowns
20
- # and version-level notice deduplication across chi web restarts.
20
+ # and version-level notice deduplication across chi web restarts, and
21
+ # keeps AutoUpdate's record (the run in progress, the versions it gave
22
+ # up on) beside them.
23
+ #
24
+ # It runs when update.notify is on, or when update.auto is install
25
+ # (AutoUpdate needs the answer): with notify off the page and the phone
26
+ # are told nothing, the hub's `available` stays nil.
21
27
  class UpdateCheck
22
28
  FIRST_CHECK = 30
23
29
  # A failed check (offline at start) tries again this soon, not a whole check_hours later.
24
30
  RETRY = 3600
25
31
 
32
+ # @param enabled [Boolean] update.notify: the answer goes to the page
33
+ # @param auto [Boolean] update.auto is install: check even when +enabled+ is not
26
34
  def initialize(hub:, enabled: Config.get("update.notify") == "on",
35
+ auto: Config.get("update.auto") == "install",
27
36
  check_hours: Config.get("update.check_hours"),
28
37
  min_age_hours: Config.get("update.min_age_hours"),
29
38
  fetcher: -> { GemUpdate.new.releases }, clock: -> { Time.now },
@@ -31,7 +40,8 @@ module Samagotchi
31
40
  gem_spec: InstalledGem.spec, bundler: nil, gem_dir: ChiHome.gem_dir(gem_spec: gem_spec),
32
41
  installed_versions: InstalledVersions.new)
33
42
  @hub = hub
34
- @enabled = enabled
43
+ @notify = enabled
44
+ @enabled = enabled || auto
35
45
  @check_seconds = [check_hours.to_i, 1].max * 3600
36
46
  @min_age_seconds = [min_age_hours.to_i, 0].max * 3600
37
47
  @fetcher = fetcher
@@ -55,7 +65,7 @@ module Samagotchi
55
65
  return self if @thread&.alive?
56
66
 
57
67
  @stopped = false
58
- @hub.update_available(available: still_newer(@state[:available]), blocked: @blocked)
68
+ @hub.update_available(available: @notify ? still_newer(@state[:available]) : nil, blocked: @blocked)
59
69
  @thread = Thread.new { run_loop }
60
70
  self
61
71
  end
@@ -80,11 +90,11 @@ module Samagotchi
80
90
  releases = @fetcher.call
81
91
  available = available_release(releases, now)&.version
82
92
  notified = @state[:notified]
83
- notify = available && !notified.include?(available)
84
- next_state = { checked_at: now.iso8601, available: available,
85
- notified: notify ? (notified + [available]) : notified }
93
+ notify = @notify && available && !notified.include?(available)
94
+ next_state = @state.merge(checked_at: now.iso8601, available: available,
95
+ notified: notify ? (notified + [available]) : notified)
86
96
  persist(next_state)
87
- @hub.update_available(available: available, blocked: @blocked, notify: !!notify)
97
+ @hub.update_available(available: @notify ? available : nil, blocked: @blocked, notify: !!notify)
88
98
  @failed = false
89
99
  true
90
100
  rescue StandardError => e
@@ -94,6 +104,36 @@ module Samagotchi
94
104
  end
95
105
  end
96
106
 
107
+ # The release the last check found and this chi (and the installed
108
+ # ones) are still behind, whether or not the page was told.
109
+ # @return [String, nil]
110
+ def available
111
+ still_newer(@state[:available])
112
+ end
113
+
114
+ # AutoUpdate's record of the run it started: {version:, from:, phase:,
115
+ # kept:, workers:, pushed:, detail:} (symbol keys), nil when none.
116
+ # @return [Hash, nil]
117
+ def auto_state
118
+ @checking.synchronize { @state[:auto]&.dup }
119
+ end
120
+
121
+ # Whether AutoUpdate already tried +version+ and failed (one automatic
122
+ # try per version).
123
+ def auto_failed?(version)
124
+ @checking.synchronize { @state[:auto_failed].include?(version) }
125
+ end
126
+
127
+ # Saves AutoUpdate's record (nil clears it); +failed+ adds a version to
128
+ # the ones it gave up on.
129
+ def save_auto(auto, failed: nil)
130
+ @checking.synchronize do
131
+ failed_list = failed && !@state[:auto_failed].include?(failed) ? @state[:auto_failed] + [failed] : @state[:auto_failed]
132
+ persist(@state.merge(auto: auto, auto_failed: failed_list))
133
+ end
134
+ nil
135
+ end
136
+
97
137
  # Why chi web can't install a gem here (the notice's and POST /api/update's
98
138
  # one answer), or nil: a source checkout, a Bundler run, a gem dir it
99
139
  # can't write.
@@ -158,19 +198,25 @@ module Samagotchi
158
198
  def blocked_reason = self.class.blocked_reason(gem_spec: @gem_spec, bundler: @bundler, gem_dir: @gem_dir)
159
199
 
160
200
  def load_state
161
- return { checked_at: nil, available: nil, notified: [] } unless File.file?(@state_file)
201
+ return empty_state unless File.file?(@state_file)
162
202
 
163
203
  data = JSON.parse(File.read(@state_file))
164
204
  {
165
205
  checked_at: data["checked_at"],
166
206
  available: data["available"],
167
- notified: Array(data["notified"]).select { |version| version.is_a?(String) }
207
+ notified: strings(data["notified"]),
208
+ auto: data["auto"].is_a?(Hash) ? data["auto"].transform_keys(&:to_sym) : nil,
209
+ auto_failed: strings(data["auto_failed"])
168
210
  }
169
211
  rescue JSON::ParserError, SystemCallError => e
170
212
  Log.warn(:web, "update_check_failed", error: e.class.name, msg: e.message)
171
- { checked_at: nil, available: nil, notified: [] }
213
+ empty_state
172
214
  end
173
215
 
216
+ def empty_state = { checked_at: nil, available: nil, notified: [], auto: nil, auto_failed: [] }
217
+
218
+ def strings(list) = Array(list).select { |version| version.is_a?(String) }
219
+
174
220
  def persist(state)
175
221
  FileUtils.mkdir_p(File.dirname(@state_file))
176
222
  AtomicFile.write(@state_file, JSON.generate(state), perm: 0o600)
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require_relative "../child_env"
7
+ require_relative "../chi_home"
8
+ require_relative "../client_id"
9
+ require_relative "../installed_gem"
10
+ require_relative "../live_versions"
11
+ require_relative "../log"
12
+ require_relative "../session"
13
+ require_relative "../session_manager"
14
+ require_relative "../version"
15
+ require_relative "update_check"
16
+
17
+ module Samagotchi
18
+ module Web
19
+ # The three update operations chi web runs for its owner, one at a time
20
+ # (App's routes ask for them, and so does AutoUpdate): an install
21
+ # (`chi update --to V` in a child), a pass restarting the stale workers,
22
+ # and chi web's own restart. Each answers at once, :started (it runs on
23
+ # its own thread, its progress and result going out as `chi_update`
24
+ # frames) or a refusal; the caller decides what a refusal says.
25
+ class UpdateOps
26
+ # chi update's own words when it printed no rows.
27
+ class UpdateFailed < StandardError; end
28
+
29
+ # @param hub [SessionHub, nil] the frames' publisher
30
+ # @param manager [#restart_session] SessionManager
31
+ # @param state_dir [String, nil] the sessions dir (nil: the default)
32
+ # @param runner [#call] `(argv, env:, progress:) → [stdout, success]`
33
+ # runs the chi update child
34
+ # @param blocked [#call] why this install can't take a gem, or nil
35
+ def initialize(hub:, manager: SessionManager, state_dir: nil, runner: nil, blocked: nil)
36
+ @hub = hub
37
+ @manager = manager
38
+ @state_dir = state_dir
39
+ @runner = runner || method(:run_update_command)
40
+ @blocked = blocked || -> { UpdateCheck.blocked_reason }
41
+ @mutex = Mutex.new
42
+ @running = false
43
+ @restart_web = nil
44
+ end
45
+
46
+ # Set by Server.start: the lambda that has chi web restart itself
47
+ # (true; :no_wrapper when there is nothing to exec; false when one was
48
+ # already asked for).
49
+ attr_writer :restart_web
50
+
51
+ # Whether the restart lambda is there (a stand-in server has none).
52
+ def restart_web? = !@restart_web.nil?
53
+
54
+ # Why a gem can't be installed here (UpdateCheck.blocked_reason), or nil.
55
+ def blocked_reason = @blocked.call
56
+
57
+ # Whether an operation runs (or chi web is restarting).
58
+ def running? = @mutex.synchronize { @running }
59
+
60
+ # Runs `chi update --to +version+` on a thread.
61
+ # @param wait [Boolean] block until it ends and answer its final
62
+ # `{state:, rows:}` instead of :started
63
+ # @return [Symbol, Hash] :started, :busy (another operation runs) or
64
+ # :start_failed (no thread); with wait: the result
65
+ def install(version, wait: false, auto: false)
66
+ return :busy unless claim
67
+
68
+ @hub.publish_update(state: "running", rows: [], operation: "install", version: version, auto: auto)
69
+ thread = Thread.new { perform_install(version, auto) }
70
+ wait ? thread.value : :started
71
+ rescue ThreadError => e
72
+ release
73
+ Log.warn(:web, "update_start_failed", error: e.class.name, msg: e.message)
74
+ @hub&.publish_update(state: "failed", rows: [
75
+ { "component" => "chi update", "status" => "failed (couldn't start update)", "failed" => true, "kept" => [] }
76
+ ], operation: "install", auto: auto)
77
+ :start_failed
78
+ end
79
+
80
+ # Restarts every stale worker (the worker refuses atomically when busy).
81
+ # @param select [#call, nil] asked of each stale worker (a
82
+ # LiveVersions::Worker): false leaves it, as a `skipped` row
83
+ # @return [Symbol, Hash] as #install
84
+ def restart_workers(wait: false, auto: false, select: nil)
85
+ return :busy unless claim
86
+
87
+ @hub.publish_update(state: "running", rows: [], operation: "workers", auto: auto)
88
+ thread = Thread.new { perform_worker_restarts(auto, select) }
89
+ wait ? thread.value : :started
90
+ rescue ThreadError => e
91
+ release
92
+ Log.warn(:web, "worker_restart_start_failed", error: e.class.name, msg: e.message)
93
+ @hub&.publish_update(state: "failed", rows: [
94
+ { "state" => "failed", "reason" => "couldn't start worker restarts" }
95
+ ], operation: "workers", auto: auto)
96
+ :start_failed
97
+ end
98
+
99
+ # The exec replaces this process: an update's chi update child (its
100
+ # pipes read here) or a worker restart pass would die mid-way, so
101
+ # neither may run, and none starts once the restart is asked for.
102
+ # @return [Symbol] :unavailable (no restart lambda), :busy, :started,
103
+ # :no_wrapper or :requested (already restarting)
104
+ def restart_web
105
+ return :unavailable unless @restart_web
106
+
107
+ busy = @mutex.synchronize do
108
+ next true if @running
109
+
110
+ @running = true # held for good once restarting: the exec ends this process
111
+ false
112
+ end
113
+ return :busy if busy
114
+
115
+ case @restart_web.call
116
+ when true then return :started
117
+ when :no_wrapper then answer = :no_wrapper
118
+ else answer = :requested
119
+ end
120
+ release
121
+ answer
122
+ end
123
+
124
+ # The chi update child: with a chi home, the home's chi (its gem dirs
125
+ # are what new workers activate); else this Ruby and the wrapper.
126
+ def update_argv(version, home: ChiHome.current)
127
+ args = ["update", "--to", version, "--no-desktop", "--json"]
128
+ return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] if home
129
+
130
+ [RbConfig.ruby, InstalledGem.wrapper, *args]
131
+ end
132
+
133
+ private
134
+
135
+ def claim
136
+ @mutex.synchronize do
137
+ next false if @running
138
+
139
+ @running = true
140
+ end
141
+ end
142
+
143
+ def release
144
+ @mutex.synchronize { @running = false }
145
+ end
146
+
147
+ def perform_install(version, auto)
148
+ argv = update_argv(version)
149
+ said = nil
150
+ output, success = @runner.call(argv, env: ChildEnv.unbundled,
151
+ progress: lambda { |line|
152
+ said = line unless line.strip.empty?
153
+ @hub.publish_update(state: "running", rows: [], progress: line,
154
+ operation: "install", version: version, auto: auto)
155
+ })
156
+ rows = update_rows(output)
157
+ # No rows (a usage error, a refusal): what the child said is the reason.
158
+ raise UpdateFailed, said || output.lines.map(&:strip).find { |line| !line.empty? } || "chi update printed no rows" unless rows
159
+
160
+ # The installed version as of now (the hub's own look is up to a probe interval old), before "done".
161
+ @hub.refresh_installed if success
162
+ state = success ? "done" : "failed"
163
+ @hub.publish_update(state: state, rows: rows, operation: "install", version: version, auto: auto)
164
+ { state: state, rows: rows }
165
+ rescue StandardError => e
166
+ Log.exception(:web, "update_failed", e)
167
+ rows = [{ "component" => "chi update", "status" => "failed (#{e.message.lines.first.to_s.strip})",
168
+ "failed" => true, "kept" => [] }]
169
+ @hub.publish_update(state: "failed", rows: rows, operation: "install", version: version, auto: auto)
170
+ { state: "failed", rows: rows }
171
+ ensure
172
+ release
173
+ end
174
+
175
+ # chi update --json's rows, or nil when it printed none.
176
+ def update_rows(output)
177
+ rows = JSON.parse(output)
178
+ rows if rows.is_a?(Array) && rows.all?(Hash)
179
+ rescue JSON::ParserError
180
+ nil
181
+ end
182
+
183
+ def run_update_command(argv, env:, progress:)
184
+ Open3.popen3(env, *argv) do |stdin, stdout, stderr, wait_thread|
185
+ stdin.close
186
+ stderr_thread = Thread.new { stderr.each_line { |line| progress.call(line.chomp) } }
187
+ output = stdout.read
188
+ stderr_thread.value
189
+ [output, wait_thread.value.success?]
190
+ end
191
+ end
192
+
193
+ # One worker's restart as its row.
194
+ def restart_one(worker)
195
+ restarted = @manager.restart_session(worker.session_id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
196
+ @hub.touch(worker.session_id)
197
+ { "session_id" => restarted.session_id, "state" => "restarted", "from_version" => restarted.from_version,
198
+ "version" => restarted.version }
199
+ rescue SessionManager::RestartRefused => e
200
+ # detail: the refusal's own words (RestartRefused.words), the reason code for clients.
201
+ { "session_id" => worker.session_id, "state" => "refused", "reason" => e.reason.to_s,
202
+ "detail" => e.message, "holds" => e.holds.map(&:to_s) }
203
+ rescue SessionManager::OwnedByTUI => e
204
+ { "session_id" => worker.session_id, "state" => "owned_by_tui", "detail" => e.message }
205
+ rescue StandardError => e
206
+ Log.exception(:web, "worker_restart_failed", e, sid: worker.session_id)
207
+ { "session_id" => worker.session_id, "state" => "failed", "reason" => e.class.name, "detail" => e.message }
208
+ end
209
+
210
+ # A worker the caller's +select+ left: still on the old chi, to restart when it is idle.
211
+ def skipped(worker)
212
+ { "session_id" => worker.session_id, "state" => "skipped", "reason" => "not_idle",
213
+ "detail" => "not idle for long enough: it restarts when it is idle" }
214
+ end
215
+
216
+ def default_state_dir = @state_dir || Session.default_state_dir
217
+
218
+ def perform_worker_restarts(auto, select)
219
+ # Installed now: the hub's own look is up to a full probe old (nil right after chi web started).
220
+ target = @hub.refresh_installed || VERSION
221
+ rows = []
222
+ LiveVersions.stale_workers(target, state_dir: default_state_dir).each do |worker|
223
+ row = select && !select.call(worker) ? skipped(worker) : restart_one(worker)
224
+ rows << row
225
+ @hub.publish_update(state: "running", rows: rows.dup, operation: "workers", auto: auto)
226
+ end
227
+ state = rows.any? { |row| row["state"] == "failed" } ? "failed" : "done"
228
+ @hub.publish_update(state: state, rows: rows, operation: "workers", auto: auto)
229
+ { state: state, rows: rows }
230
+ rescue StandardError => e
231
+ Log.exception(:web, "worker_restart_failed", e)
232
+ rows = [{ "state" => "failed", "reason" => e.class.name, "detail" => e.message }]
233
+ @hub.publish_update(state: "failed", rows: rows, operation: "workers", auto: auto)
234
+ { state: "failed", rows: rows }
235
+ ensure
236
+ release
237
+ end
238
+ end
239
+ end
240
+ end
@@ -129,7 +129,7 @@ module Samagotchi
129
129
  end
130
130
 
131
131
  def note(entry, record, wake:, turn_id: nil)
132
- TaskFinishNote.note(record, limit_sec: entry.limit_sec, wake: wake, turn_id: turn_id,
132
+ TaskFinishNote.note(record, limit_sec: entry.limit_sec, early_sec: entry.early_sec, wake: wake, turn_id: turn_id,
133
133
  cap: KernelLoop.resolve_output_char_cap(nil))
134
134
  end
135
135
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.138.0
4
+ version: 0.139.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -752,6 +752,7 @@ files:
752
752
  - lib/samagotchi/vision_support.rb
753
753
  - lib/samagotchi/waiting_steer.rb
754
754
  - lib/samagotchi/web/app.rb
755
+ - lib/samagotchi/web/auto_update.rb
755
756
  - lib/samagotchi/web/capabilities.rb
756
757
  - lib/samagotchi/web/editor.rb
757
758
  - lib/samagotchi/web/grants.rb
@@ -951,6 +952,7 @@ files:
951
952
  - lib/samagotchi/web/tunnel.rb
952
953
  - lib/samagotchi/web/tunnel/tailscale.rb
953
954
  - lib/samagotchi/web/update_check.rb
955
+ - lib/samagotchi/web/update_ops.rb
954
956
  - lib/samagotchi/web/verify_fresh_routes.rb
955
957
  - lib/samagotchi/worker.rb
956
958
  - lib/samagotchi/worker_idle_exit.rb