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
|
@@ -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); +
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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
|
-
|
|
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
|
|
72
|
-
#
|
|
73
|
-
# web's task_stop.js and the
|
|
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
|
-
|
|
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)}
|
|
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.
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
202
|
-
|
|
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 = @
|
|
742
|
+
blocked = @update_ops.blocked_reason
|
|
738
743
|
return json_response(409, { error: "update_blocked", reason: blocked }) if blocked
|
|
739
744
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
busy
|
|
884
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
else
|
|
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 } })
|