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
@@ -19,12 +19,14 @@ module Samagotchi
19
19
  # and anytime commands run on threads of their own.
20
20
  class TaskWatch
21
21
  # A watched task: +limit_sec+, the limit an execute handed it over at
22
- # (nil for task_create's); +note_only+, the user stopped the turn that
22
+ # (nil for task_create's); +early_sec+, the seconds an execute had run
23
+ # when a message from the user handed it over early (nil otherwise);
24
+ # +note_only+, the user stopped the turn that
23
25
  # started or waited on it (M5): its finish is a plain note, no wake;
24
26
  # +failures+, how often its note couldn't be written (#put_back) or the
25
27
  # look for it failed (#look_failed).
26
- Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
27
- def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
28
+ Entry = Data.define(:id, :limit_sec, :early_sec, :note_only, :failures) do
29
+ def initialize(id:, limit_sec: nil, early_sec: nil, note_only: false, failures: 0) = super
28
30
  end
29
31
 
30
32
  # A task whose note or look failed this often still gets tried at each
@@ -41,6 +43,7 @@ module Samagotchi
41
43
  TASK_LINE = /\A#{Tools::TaskRuntime::RAN_AS_LINE}(?:\[task_(?:wait|get|stop)\]\n)?task_id: (\S+)/
42
44
  LOOK_TOOLS = %w[task_wait task_get task_stop].freeze
43
45
  HANDED_OVER_LIMIT = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s limit/
46
+ HANDED_OVER_EARLY = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s mark/
44
47
 
45
48
  MODE_KEY = "session.task_wakes"
46
49
  MODES = %w[wake queue off].freeze
@@ -75,16 +78,18 @@ module Samagotchi
75
78
  output = event[:output].to_s
76
79
  output = output.scrub unless output.valid_encoding?
77
80
  if (id = Tools::TaskRuntime.created_id(tool, output))
78
- add(id, limit_sec: tool == Tools::Execute::NAME ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil)
81
+ executed = tool == Tools::Execute::NAME
82
+ add(id, limit_sec: executed ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil,
83
+ early_sec: executed ? output[HANDED_OVER_EARLY, 1]&.to_i : nil)
79
84
  elsif LOOK_TOOLS.include?(tool) && (id = output[TASK_LINE, 1])
80
85
  touched(id)
81
86
  remove(id) if seen?(tool, output)
82
87
  end
83
88
  end
84
89
 
85
- def add(id, limit_sec: nil)
90
+ def add(id, limit_sec: nil, early_sec: nil)
86
91
  @mutex.synchronize do
87
- @entries[id] = Entry.new(id: id, limit_sec: limit_sec)
92
+ @entries[id] = Entry.new(id: id, limit_sec: limit_sec, early_sec: early_sec)
88
93
  @turn_ids << id
89
94
  end
90
95
  end
@@ -264,8 +264,8 @@ module Samagotchi
264
264
  "ok"
265
265
  end
266
266
 
267
- # An execute still running at its limit, handed over as a task
268
- # (Tools::Execute::HANDED_OVER).
267
+ # An execute still running at its limit (or when a message from the user
268
+ # waited), handed over as a task (Tools::Execute::HANDED_OVER).
269
269
  def handed_over?(tool_name, text)
270
270
  return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
271
271
 
@@ -273,7 +273,7 @@ module Samagotchi
273
273
  text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
274
274
  end
275
275
 
276
- HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
276
+ HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} #{Tools::Execute::HANDED_OVER_AFTER}/
277
277
 
278
278
  def execute_failed?(text)
279
279
  code = execute_exit_code(text)
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  TOOL_SCHEMAS = [
21
21
  {
22
22
  name: "execute",
23
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
23
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit, or after a few seconds when a message from the user is waiting, keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {
@@ -395,7 +395,7 @@ module Samagotchi
395
395
  },
396
396
  {
397
397
  name: "delegate_result",
398
- description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
398
+ description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer, or a message from the user arrives (the child keeps running). A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
399
399
  parameters: {
400
400
  type: "object",
401
401
  properties: {
@@ -92,6 +92,7 @@ module Samagotchi
92
92
  end,
93
93
  Execute::NAME => lambda do |call, kctx|
94
94
  Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
95
+ message_waiting: input_waiting_proc(kctx),
95
96
  **participant_execute(kctx, project_env(kctx).merge(parent_env(kctx))))
96
97
  end,
97
98
  RegisterReminder::NAME => lambda do |call, kctx|
@@ -202,7 +203,7 @@ module Samagotchi
202
203
 
203
204
  # A person's message waits for the turn's next boundary
204
205
  # (Engine#input_waiting?, through its PeerView): a task_wait returns on
205
- # it. A kernel whose peers can't tell: never.
206
+ # it, an execute hands its command over early. A kernel whose peers can't tell: never.
206
207
  def input_waiting_proc(kctx)
207
208
  peers = kctx.peers
208
209
  -> { peers.respond_to?(:input_waiting?) && !!peers.input_waiting? }
@@ -63,6 +63,7 @@ module Samagotchi
63
63
  sd = peers.state_dir || Session.default_state_dir
64
64
  key = [peers.session_id, child_id]
65
65
  cancelled = -> { peers.cancelled? }
66
+ message_waiting = -> { peers.respond_to?(:input_waiting?) && !!peers.input_waiting? }
66
67
  relay = peers.respond_to?(:relay) ? peers.relay : nil
67
68
  reports = reports_mode(peers)
68
69
  others = relay && others_for(peers.session_id, child_id, relay, sd)
@@ -80,7 +81,7 @@ module Samagotchi
80
81
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
81
82
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
82
83
  owner_grace: owner_grace, interject: others && -> { others.poll },
83
- turn: cursor.turn)
84
+ turn: cursor.turn, message_waiting: message_waiting)
84
85
  left -= monotonic - started
85
86
  unless wait.status == :waiting_for_answer &&
86
87
  DelegateRelay.relayable?(relay, wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session),
@@ -91,7 +92,9 @@ module Samagotchi
91
92
  file = movement.cursor.report_claim&.file || wait.file
92
93
  return already_reported_result(child_id, file, state_dir: sd)
93
94
  end
94
- return already_reported_result(child_id, report_claim.file, state_dir: sd) if report_claim && wait.status == :timeout
95
+ if report_claim && %i[timeout message_waiting].include?(wait.status)
96
+ return already_reported_result(child_id, report_claim.file, state_dir: sd)
97
+ end
95
98
 
96
99
  text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
97
100
  return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
@@ -158,6 +161,22 @@ module Samagotchi
158
161
  result(child_id, "running", "wait canceled; the child keeps running; chi brings its reply as a delegate report")
159
162
  end
160
163
 
164
+ # A person's message waits for this turn's next step (ReplyWait's
165
+ # :message_waiting, Engine#input_waiting?): the wait ends with no reply
166
+ # yet and the child untouched, as a cancel does. The cursor didn't
167
+ # move, so the reply still comes (a delegate report, or the next
168
+ # delegate_result).
169
+ def message_waiting_result(child_id, reports: "off")
170
+ head = "no reply yet: a message from the user is waiting; the child keeps running. Read the message first."
171
+ if reports == "off"
172
+ return result(child_id, "running", "#{head} delegate_result #{child_id} waits again; chi --attach #{child_id} shows it.")
173
+ end
174
+
175
+ result(child_id, "running",
176
+ "#{head} chi brings its reply as a delegate report; delegate_result #{child_id} waits again only if " \
177
+ "this turn needs it.")
178
+ end
179
+
161
180
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
162
181
 
163
182
  REPORT_NEXT_REPLY = "chi brings the child's next reply here by itself; don't wait for it with delegate_result."
@@ -230,6 +249,8 @@ module Samagotchi
230
249
  result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports, strategy: strategy))
231
250
  when :canceled
232
251
  canceled_result(child_id, reports: reports)
252
+ when :message_waiting
253
+ message_waiting_result(child_id, reports: reports)
233
254
  when :no_reply
234
255
  result(child_id, status, "#{no_reply_text(wait, child_id, strategy: strategy)}; its session shows what happened")
235
256
  when :worker_gone
@@ -68,10 +68,20 @@ module Samagotchi
68
68
  # record is gone or is older than RETENTION_DAYS.
69
69
  ORPHAN_AGE_SEC = 24 * 3600
70
70
  RETENTION_DAYS = 14
71
- # The first words of a command still running at the limit and handed
72
- # over as a task (handed_over_line). TaskRuntime.handed_over_id, the
73
- # web's task_stop.js and the slow-commands bundle match on them.
71
+ # The first words of a command still running and handed over as a
72
+ # task (handed_over_line at the limit, early_handed_over_line for a
73
+ # message). TaskRuntime.handed_over_id, the web's task_stop.js and the
74
+ # slow-commands bundle match on them; the bundle reads only the limit's
75
+ # form, "at the 120s limit".
74
76
  HANDED_OVER = "Still running at the"
77
+ # What follows HANDED_OVER on the first line, as regexp source: "120s
78
+ # limit:" (the limit) or "4s mark:" (a message from the user ended the
79
+ # wait early). Every matcher of the line builds on it.
80
+ HANDED_OVER_AFTER = "(?:\\d+s limit|\\d+s mark):"
81
+ # A message from the user waiting for the turn hands a command over
82
+ # early, but never before it has run this long: a short command ends
83
+ # on its own first, and its result is the better answer.
84
+ EARLY_HAND_OVER_FLOOR_SEC = 3
75
85
 
76
86
  def self.name = NAME
77
87
 
@@ -80,6 +90,14 @@ module Samagotchi
80
90
  "#{HANDED_OVER} #{Integer(limit_sec)}s limit: moved to a background task, not killed."
81
91
  end
82
92
 
93
+ # A hand-over result's first line when a message from the user is
94
+ # waiting (the command ran +elapsed_sec+ seconds); its "task_id: …"
95
+ # line follows it.
96
+ def self.early_handed_over_line(elapsed_sec)
97
+ "#{HANDED_OVER} #{Integer(elapsed_sec)}s mark: a message from the user is waiting, " \
98
+ "so the command moved to a background task, not killed."
99
+ end
100
+
83
101
  # @param cancelled [#call] true once the user stopped the turn: the
84
102
  # command is killed (or not started) and its output so far returned
85
103
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
@@ -91,7 +109,13 @@ module Samagotchi
91
109
  # may set those keys (task_create refuses them from the model)
92
110
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
93
111
  # nil reads execute.on_timeout
94
- def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
112
+ # @param message_waiting [#call] true once a person's message waits for
113
+ # the turn's next step (Engine#input_waiting?): in :task mode, after
114
+ # EARLY_HAND_OVER_FLOOR_SEC, the command is handed over as a task at
115
+ # once, not at the limit. Never read in :kill mode, which never
116
+ # hands a command over.
117
+ def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil,
118
+ message_waiting: -> { false })
95
119
  return not_run(stop_reason) if cancelled.call
96
120
 
97
121
  command = command.strip
@@ -101,7 +125,8 @@ module Samagotchi
101
125
  sweep_leftovers_once
102
126
  timeout_sec = timeout_seconds
103
127
  stdout, stderr, status, held_open = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
104
- env: env, on_timeout: on_timeout_mode(on_timeout))
128
+ env: env, on_timeout: on_timeout_mode(on_timeout),
129
+ message_waiting: message_waiting)
105
130
 
106
131
  stdout_block = output_block("stdout", stdout)
107
132
  stderr_block = output_block("stderr", stderr)
@@ -162,12 +187,14 @@ module Samagotchi
162
187
  # group reading the terminal would stop on SIGTTIN. The shell is
163
188
  # detached at once; the waiter it returns reaps it, also after a
164
189
  # hand-over, so it never stays a zombie.
165
- def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil, on_timeout: :task)
190
+ def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil, on_timeout: :task,
191
+ message_waiting: -> { false })
166
192
  status = nil
167
193
  timed_out = false
168
194
  was_cancelled = false
169
195
  aborted = false
170
196
  handed_over = false
197
+ early_tried = false
171
198
  held_open = false
172
199
  started_at = Time.now
173
200
  started = monotonic_time
@@ -201,6 +228,19 @@ module Samagotchi
201
228
  elsif monotonic_time > deadline
202
229
  timed_out = true
203
230
  break
231
+ elsif !early_tried && on_timeout == :task && monotonic_time - started >= EARLY_HAND_OVER_FLOOR_SEC &&
232
+ message_waiting.call
233
+ # One try: a command that can't be adopted goes on to its limit.
234
+ early_tried = true
235
+ task = TaskRecord.running(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at.utc.iso8601,
236
+ output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
237
+ text = hand_over(task, waiter: wait_thr, timeout_sec: timeout_sec, dir: dir,
238
+ early_sec: monotonic_time - started)
239
+ if text
240
+ handed_over = true
241
+ Log.info(:turn, "execute_handed_over", id: id, after_sec: (monotonic_time - started).round, early: true)
242
+ raise HandedOver, text
243
+ end
204
244
  end
205
245
  end
206
246
  # Ended right at the deadline: finished, not timed out.
@@ -345,8 +385,10 @@ module Samagotchi
345
385
  # (+dir+); a back-pointer in that dir names the record for the sweep. nil when it can't be adopted (the caller
346
386
  # then kills it as in :kill mode) or when it ended meanwhile (the
347
387
  # caller then reads it as finished).
388
+ # @param early_sec [Numeric, nil] set when a message from the user ended
389
+ # the wait before the limit: the seconds the command had run
348
390
  # @return [String, nil] the HANDED_OVER result
349
- def self.hand_over(task, waiter:, timeout_sec:, dir:)
391
+ def self.hand_over(task, waiter:, timeout_sec:, dir:, early_sec: nil)
350
392
  return nil unless waiter.alive?
351
393
 
352
394
  record, error = TaskRuntime.adopt(task)
@@ -356,8 +398,8 @@ module Samagotchi
356
398
  TaskRuntime.record_exit_status(waiter, record.exit_code_path, task_id: record.id)
357
399
  # Ended between the deadline and the adopt: the record says how.
358
400
  TaskRuntime.get_record(record.id) unless waiter.alive?
359
- lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
360
- "Wait for it with task_wait (it may take minutes); task_stop stops it."]
401
+ lines = [early_sec ? early_handed_over_line(early_sec) : handed_over_line(timeout_sec), "task_id: #{record.id}",
402
+ "#{"Read the message first. " if early_sec}Wait for it with task_wait (it may take minutes); task_stop stops it."]
361
403
  { "stdout" => record.output_path, "stderr" => record.stderr_path }.each do |label, path|
362
404
  tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
363
405
  lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
@@ -269,7 +269,7 @@ module Samagotchi
269
269
  end
270
270
 
271
271
  def handed_over_pattern
272
- @handed_over_pattern ||= /\A#{RAN_AS_LINE}(?:\[#{Execute::NAME}\]\n)?#{Regexp.escape(Execute::HANDED_OVER)} \d+s limit:[^\n]*\ntask_id: (\S+)/
272
+ @handed_over_pattern ||= /\A#{RAN_AS_LINE}(?:\[#{Execute::NAME}\]\n)?#{Regexp.escape(Execute::HANDED_OVER)} #{Execute::HANDED_OVER_AFTER}[^\n]*\ntask_id: (\S+)/
273
273
  end
274
274
 
275
275
  # A task id as task_create makes them: no path parts.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.138.0"
4
+ VERSION = "0.139.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -4,8 +4,6 @@ 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
7
  require "rubygems/version"
10
8
  require "time"
11
9
  require "uri"
@@ -76,6 +74,7 @@ require_relative "session_control"
76
74
  require_relative "verify_fresh_routes"
77
75
  require_relative "push_routes"
78
76
  require_relative "update_check"
77
+ require_relative "update_ops"
79
78
  require_relative "proxy_gate"
80
79
  require_relative "../log"
81
80
  require_relative "../paths"
@@ -130,7 +129,13 @@ module Samagotchi
130
129
  # A callable the event loops ask whether the server still runs;
131
130
  # Server.start points it at WEBrick's status once it has the server.
132
131
  attr_writer :server_running
133
- attr_writer :restart_web
132
+ # The update operations the routes ask for (Server.start hands the same to AutoUpdate).
133
+ attr_reader :update_ops
134
+
135
+ # Set by Server.start: how chi web restarts itself (UpdateOps#restart_web=).
136
+ def restart_web=(restarter)
137
+ @update_ops.restart_web = restarter
138
+ end
134
139
 
135
140
  # @param bridge_wait_timeout [Float] bounded seconds to wait for a
136
141
  # freshly-spawned worker's bridge before answering POST /api/sessions.
@@ -165,6 +170,8 @@ module Samagotchi
165
170
  # nil: off (no https public_url, or web.push.enabled false)
166
171
  # @param start_dir [String] the folder chi web starts in (StartDir):
167
172
  # where a new session without a dir starts (nil: the cwd)
173
+ # @param update_ops [UpdateOps, nil] the update operations (nil: built
174
+ # from +update_runner+ and +update_blocked+, the specs' seams)
168
175
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
169
176
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, hub: nil,
170
177
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
@@ -173,7 +180,7 @@ module Samagotchi
173
180
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
174
181
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
175
182
  grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil, update_runner: nil,
176
- update_blocked: nil)
183
+ update_blocked: nil, update_ops: nil)
177
184
  @start_dir = start_dir
178
185
  @manager = manager || SessionManager
179
186
  @registry = registry
@@ -198,10 +205,8 @@ module Samagotchi
198
205
  @refused_logged = {}
199
206
  @wait_poll_interval = wait_poll_interval
200
207
  @push = push
201
- @update_mutex = Mutex.new
202
- @update_running = false
203
- @update_runner = update_runner || method(:run_update_command)
204
- @update_blocked = update_blocked || -> { UpdateCheck.blocked_reason }
208
+ @update_ops = update_ops || UpdateOps.new(hub: hub, manager: @manager, state_dir: state_dir,
209
+ runner: update_runner, blocked: update_blocked)
205
210
  init_participant_access(grants, state_dir)
206
211
  init_proxy_gate(public_url, public_token)
207
212
  end
@@ -734,169 +739,36 @@ module Samagotchi
734
739
  end
735
740
 
736
741
  # Found out here, not from the hub: UpdateCheck (off with update.notify: off) is what tells it.
737
- blocked = @update_blocked.call
742
+ blocked = @update_ops.blocked_reason
738
743
  return json_response(409, { error: "update_blocked", reason: blocked }) if blocked
739
744
 
740
- already_running = @update_mutex.synchronize do
741
- if @update_running
742
- true
743
- else
744
- @update_running = true
745
- false
746
- end
747
- end
748
- return error_response(409, "update_running", UPDATE_RUNNING) if already_running
749
-
750
- @hub.publish_update(state: "running", rows: [], operation: "install", version: version)
751
- Thread.new { perform_update(version) }
752
- json_response(202, { state: "running", version: version })
753
- rescue ThreadError => e
754
- @update_mutex.synchronize { @update_running = false }
755
- Log.warn(:web, "update_start_failed", error: e.class.name, msg: e.message)
756
- @hub&.publish_update(state: "failed", rows: [
757
- { "component" => "chi update", "status" => "failed (couldn't start update)", "failed" => true, "kept" => [] }
758
- ], operation: "install")
759
- error_response(503, "update_start_failed", "couldn't start chi update")
760
- end
761
-
762
- # The chi update child: with a chi home, the home's chi (its gem dirs
763
- # are what new workers activate); else this Ruby and the wrapper.
764
- def update_argv(version, home: ChiHome.current)
765
- args = ["update", "--to", version, "--no-desktop", "--json"]
766
- return home.boot_argv(run: ChiHome::CHI_BOOT) + ["--", *args] if home
767
-
768
- [RbConfig.ruby, InstalledGem.wrapper, *args]
769
- end
770
-
771
- def perform_update(version)
772
- argv = update_argv(version)
773
- said = nil
774
- output, success = @update_runner.call(argv, env: ChildEnv.unbundled,
775
- progress: lambda { |line|
776
- said = line unless line.strip.empty?
777
- @hub.publish_update(state: "running", rows: [], progress: line,
778
- operation: "install", version: version)
779
- })
780
- rows = update_rows(output)
781
- # No rows (a usage error, a refusal): what the child said is the reason.
782
- raise UpdateFailed, said || output.lines.map(&:strip).find { |line| !line.empty? } || "chi update printed no rows" unless rows
783
-
784
- # The installed version as of now (the hub's own look is up to a probe interval old), before "done".
785
- @hub.refresh_installed if success
786
- @hub.publish_update(state: success ? "done" : "failed", rows: rows, operation: "install", version: version)
787
- rescue StandardError => e
788
- Log.exception(:web, "update_failed", e)
789
- @hub.publish_update(state: "failed", rows: [
790
- { "component" => "chi update", "status" => "failed (#{e.message.lines.first.to_s.strip})",
791
- "failed" => true, "kept" => [] }
792
- ], operation: "install", version: version)
793
- ensure
794
- @update_mutex.synchronize { @update_running = false }
795
- end
796
-
797
- # chi update's own words when it printed no rows.
798
- class UpdateFailed < StandardError; end
799
-
800
- # chi update --json's rows, or nil when it printed none.
801
- def update_rows(output)
802
- rows = JSON.parse(output)
803
- rows if rows.is_a?(Array) && rows.all?(Hash)
804
- rescue JSON::ParserError
805
- nil
806
- end
807
-
808
- def run_update_command(argv, env:, progress:)
809
- Open3.popen3(env, *argv) do |stdin, stdout, stderr, wait_thread|
810
- stdin.close
811
- stderr_thread = Thread.new { stderr.each_line { |line| progress.call(line.chomp) } }
812
- output = stdout.read
813
- stderr_thread.value
814
- [output, wait_thread.value.success?]
745
+ case @update_ops.install(version)
746
+ when :busy then error_response(409, "update_running", UPDATE_RUNNING)
747
+ when :start_failed then error_response(503, "update_start_failed", "couldn't start chi update")
748
+ else json_response(202, { state: "running", version: version })
815
749
  end
816
750
  end
817
751
 
818
752
  def handle_restart_workers(_req)
819
753
  return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
820
754
 
821
- already_running = @update_mutex.synchronize do
822
- if @update_running
823
- true
824
- else
825
- @update_running = true
826
- false
827
- end
755
+ case @update_ops.restart_workers
756
+ when :busy then error_response(409, "update_running", UPDATE_RUNNING)
757
+ when :start_failed then error_response(503, "restart_start_failed", "couldn't start worker restarts")
758
+ else json_response(202, { state: "running" })
828
759
  end
829
- return error_response(409, "update_running", UPDATE_RUNNING) if already_running
830
-
831
- @hub.publish_update(state: "running", rows: [], operation: "workers")
832
- Thread.new { perform_worker_restarts }
833
- json_response(202, { state: "running" })
834
- rescue ThreadError => e
835
- @update_mutex.synchronize { @update_running = false }
836
- Log.warn(:web, "worker_restart_start_failed", error: e.class.name, msg: e.message)
837
- @hub&.publish_update(state: "failed", rows: [
838
- { "state" => "failed", "reason" => "couldn't start worker restarts" }
839
- ], operation: "workers")
840
- error_response(503, "restart_start_failed", "couldn't start worker restarts")
841
- end
842
-
843
- def perform_worker_restarts
844
- # Installed now: the hub's own look is up to a full probe old (nil right after chi web started).
845
- target = @hub.refresh_installed || VERSION
846
- rows = []
847
- LiveVersions.stale_workers(target, state_dir: default_state_dir).each do |worker|
848
- row = begin
849
- restarted = @manager.restart_session(worker.session_id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
850
- @hub.touch(worker.session_id)
851
- { "session_id" => restarted.session_id, "state" => "restarted", "from_version" => restarted.from_version,
852
- "version" => restarted.version }
853
- rescue SessionManager::RestartRefused => e
854
- # detail: the refusal's own words (RestartRefused.words), the reason code for clients.
855
- { "session_id" => worker.session_id, "state" => "refused", "reason" => e.reason.to_s,
856
- "detail" => e.message, "holds" => e.holds.map(&:to_s) }
857
- rescue SessionManager::OwnedByTUI => e
858
- { "session_id" => worker.session_id, "state" => "owned_by_tui", "detail" => e.message }
859
- rescue StandardError => e
860
- Log.exception(:web, "worker_restart_failed", e, sid: worker.session_id)
861
- { "session_id" => worker.session_id, "state" => "failed", "reason" => e.class.name, "detail" => e.message }
862
- end
863
- rows << row
864
- @hub.publish_update(state: "running", rows: rows.dup, operation: "workers")
865
- end
866
- state = rows.any? { |row| row["state"] == "failed" } ? "failed" : "done"
867
- @hub.publish_update(state: state, rows: rows, operation: "workers")
868
- rescue StandardError => e
869
- Log.exception(:web, "worker_restart_failed", e)
870
- @hub.publish_update(state: "failed", rows: [
871
- { "state" => "failed", "reason" => e.class.name, "detail" => e.message }
872
- ], operation: "workers")
873
- ensure
874
- @update_mutex.synchronize { @update_running = false }
875
760
  end
876
761
 
877
- # The exec replaces this process: an update's chi update child (its
878
- # pipes read here) or a worker restart pass would die mid-way, so
879
- # neither may run, and none starts once the restart is asked for.
880
762
  def handle_restart_web(_req)
881
- return error_response(503, "restart_unavailable", "chi web cannot restart itself in this process") unless @restart_web
882
-
883
- busy = @update_mutex.synchronize do
884
- next true if @update_running
885
-
886
- @update_running = true # held for good once restarting: the exec ends this process
887
- false
888
- end
889
- return error_response(409, "update_running", UPDATE_RUNNING) if busy
890
-
891
- case @restart_web.call
892
- when true then return json_response(202, { state: "restarting" })
763
+ case @update_ops.restart_web
764
+ when :unavailable then error_response(503, "restart_unavailable", "chi web cannot restart itself in this process")
765
+ when :busy then error_response(409, "update_running", UPDATE_RUNNING)
766
+ when :started then json_response(202, { state: "restarting" })
893
767
  when :no_wrapper
894
- refusal = error_response(409, "restart_unavailable", "no chi wrapper to restart into (installed with --bindir?); " \
895
- "restart chi web in its terminal")
896
- else refusal = error_response(409, "restart_requested", "chi web is already restarting")
768
+ error_response(409, "restart_unavailable", "no chi wrapper to restart into (installed with --bindir?); " \
769
+ "restart chi web in its terminal")
770
+ else error_response(409, "restart_requested", "chi web is already restarting")
897
771
  end
898
- @update_mutex.synchronize { @update_running = false }
899
- refusal
900
772
  end
901
773
 
902
774
  def unauthorized(req)
@@ -1144,7 +1016,7 @@ module Samagotchi
1144
1016
  def handle_info(req)
1145
1017
  return participant_info unless principal(req).owner?
1146
1018
 
1147
- chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil }
1019
+ chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off" }
1148
1020
  json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
1149
1021
  cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
1150
1022
  proxy: @public_url && { identity_header: @public_url.identity_header } })