samagotchi 0.145.0 → 0.147.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 +49 -1
- data/docs/architecture.md +14 -5
- data/docs/cli.md +6 -3
- data/docs/configuration.md +16 -3
- data/docs/guardrails.md +7 -2
- data/docs/internals/bridge.md +3 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/web-frontend.md +3 -2
- data/docs/memory.md +45 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +13 -6
- data/lib/samagotchi/guardrails/approval.rb +17 -4
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
- data/lib/samagotchi/guardrails/verdict.rb +5 -3
- data/lib/samagotchi/memory_command.rb +135 -7
- data/lib/samagotchi/memory_paths.rb +9 -0
- data/lib/samagotchi/memory_usage.rb +368 -0
- data/lib/samagotchi/relay.rb +2 -2
- data/lib/samagotchi/tool_activity.rb +3 -3
- data/lib/samagotchi/tool_output_cut.rb +12 -1
- data/lib/samagotchi/toolkit_suggest.rb +7 -4
- data/lib/samagotchi/tools/delegate.rb +88 -13
- data/lib/samagotchi/tools/memory.rb +12 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/auto_update.rb +60 -5
- data/lib/samagotchi/web/public/app.js +11 -18
- data/lib/samagotchi/web/public/participant_annotate.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +33 -9
- data/lib/samagotchi/web/public/revise_stack.js +6 -5
- data/lib/samagotchi/web/public/update_notice.js +22 -11
- data/lib/samagotchi/web/public/update_sheet.js +4 -1
- data/lib/samagotchi/web/public/verify_preset.js +5 -1
- data/lib/samagotchi/web/public/worker_features.js +14 -0
- data/lib/samagotchi/web/session_access_routes.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +17 -4
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +3 -1
|
@@ -18,6 +18,9 @@ module Samagotchi
|
|
|
18
18
|
class ToolkitSuggest
|
|
19
19
|
FILE = "toolkit_suggestions.jsonl"
|
|
20
20
|
QUESTION_ID = "toolkit"
|
|
21
|
+
# The defer sentinel: the choice "There is no task yet", asked as a
|
|
22
|
+
# criterion, never offered as a kit, and hidden from the web's picker.
|
|
23
|
+
UNDECIDED = "undecided"
|
|
21
24
|
# Its saved asks' name (SystemOne::Runs).
|
|
22
25
|
JUDGMENT = "toolkit.suggest"
|
|
23
26
|
PROMPT_MAX = 1500
|
|
@@ -42,7 +45,7 @@ module Samagotchi
|
|
|
42
45
|
# not the whole ask, and the ≥ 2 clause counts what is left.
|
|
43
46
|
def self.candidates(kits, default_name:)
|
|
44
47
|
kits.values.filter_map do |kit|
|
|
45
|
-
next if [default_name.to_s, Toolkit::REVIEW,
|
|
48
|
+
next if [default_name.to_s, Toolkit::REVIEW, UNDECIDED].include?(kit.name)
|
|
46
49
|
next if kit.description.to_s.empty?
|
|
47
50
|
next unless resolves?(kit.name, kits)
|
|
48
51
|
|
|
@@ -62,7 +65,7 @@ module Samagotchi
|
|
|
62
65
|
candidates = candidates(kits, default_name: default.name)
|
|
63
66
|
criteria = candidates.to_h { |kit| ["kit:#{kit.name}", "#{kit.name}: #{kit.description}"] }
|
|
64
67
|
criteria["default"] = "none of the other toolkits fits: the user's everyday default"
|
|
65
|
-
criteria[
|
|
68
|
+
criteria[UNDECIDED] = "There is no task yet; defer this choice"
|
|
66
69
|
question = SystemOne::Question.new(id: QUESTION_ID, type: :choice, instructions: INSTRUCTIONS, criteria: criteria)
|
|
67
70
|
[question, { project: project.to_s, prompt: prompt.to_s[0, PROMPT_MAX] }]
|
|
68
71
|
end
|
|
@@ -81,7 +84,7 @@ module Samagotchi
|
|
|
81
84
|
|
|
82
85
|
messages = Array(session.messages)
|
|
83
86
|
return true unless model_reply?(messages)
|
|
84
|
-
return false unless field(value(last_suggestion), :raw) ==
|
|
87
|
+
return false unless field(value(last_suggestion), :raw) == UNDECIDED
|
|
85
88
|
|
|
86
89
|
current_turn_number = turn_number(messages)
|
|
87
90
|
current_turn_number <= defer_turns.to_i && conversation_chars(messages) / CHARS_PER_TOKEN < defer_tokens.to_i
|
|
@@ -139,7 +142,7 @@ module Samagotchi
|
|
|
139
142
|
decided = raw == "default" || (candidate && confidence && confidence >= threshold.to_f)
|
|
140
143
|
would = if answer.nil?
|
|
141
144
|
"no_judgment"
|
|
142
|
-
elsif raw ==
|
|
145
|
+
elsif raw == UNDECIDED
|
|
143
146
|
"defer"
|
|
144
147
|
elsif raw == "default"
|
|
145
148
|
"default"
|
|
@@ -5,6 +5,7 @@ require_relative "../session"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../model_profile"
|
|
7
7
|
require_relative "../memory_paths"
|
|
8
|
+
require_relative "../guardrails/context"
|
|
8
9
|
require_relative "peers"
|
|
9
10
|
require_relative "delegate_wait"
|
|
10
11
|
require_relative "../child_reports"
|
|
@@ -27,6 +28,9 @@ module Samagotchi
|
|
|
27
28
|
NAME = "delegate"
|
|
28
29
|
MAX_CHILDREN_KEY = "session.max_children"
|
|
29
30
|
MAX_CHILDREN_DEFAULT = 4
|
|
31
|
+
# A path in a task: absolute, ~/ or ../, not glued to a word (a URL's
|
|
32
|
+
# /api is not one) and stopping at quotes, backticks or punctuation.
|
|
33
|
+
PATH_TOKEN = %r{(?<![\w./~-])(?:~/|\.\./|/(?!/))[^\s`'"<>()\[\]{},;&|]*}
|
|
30
34
|
# A child spawned this many seconds ago counts as running even before
|
|
31
35
|
# its worker holds the owner lock: two delegate calls in one model
|
|
32
36
|
# message come milliseconds apart, and the first child's worker is
|
|
@@ -60,19 +64,19 @@ module Samagotchi
|
|
|
60
64
|
|
|
61
65
|
cwd = cwd.to_s.strip
|
|
62
66
|
toolkit = toolkit.to_s.strip
|
|
63
|
-
child_id,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
child_id, warnings = if session.to_s.strip.empty?
|
|
68
|
+
start_child(task, parent: parent, model: model, cwd: cwd, toolkit: toolkit, state_dir: sd,
|
|
69
|
+
peers: peers)
|
|
70
|
+
elsif !cwd.empty?
|
|
71
|
+
"Error: cwd starts a new child; a follow-up with session keeps the child's folder"
|
|
72
|
+
elsif !toolkit.empty?
|
|
73
|
+
"Error: toolkit starts a new child; a follow-up keeps the child's toolkit"
|
|
74
|
+
else
|
|
75
|
+
follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
|
|
76
|
+
end
|
|
73
77
|
return child_id if child_id.start_with?("Error:")
|
|
74
78
|
|
|
75
|
-
note = warning
|
|
79
|
+
note = Array(warnings).map { |warning| "Warning: #{warning}\n" }.join
|
|
76
80
|
if wait
|
|
77
81
|
result = DelegateWait.call(child_id, peers: peers, timeout: timeout)
|
|
78
82
|
return RefText.wrap(note + result, result.respond_to?(:ref_path) ? result.ref_path : nil)
|
|
@@ -114,7 +118,8 @@ module Samagotchi
|
|
|
114
118
|
private_class_method :full_hint
|
|
115
119
|
|
|
116
120
|
# @param toolkit [String] a kit's name, "" for config's toolkit.child
|
|
117
|
-
# @return [String] the new child's id, or an Error: line
|
|
121
|
+
# @return [String, Array<String>] the new child's id, or an Error: line,
|
|
122
|
+
# and its warnings (each without the "Warning: " prefix)
|
|
118
123
|
def self.start_child(task, parent:, model:, cwd:, toolkit:, state_dir:, peers:)
|
|
119
124
|
if parent.parent_id
|
|
120
125
|
return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
|
|
@@ -142,10 +147,80 @@ module Samagotchi
|
|
|
142
147
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
143
148
|
parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
|
|
144
149
|
DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
|
|
145
|
-
|
|
150
|
+
# The line for a task that names a worktree while the child runs in
|
|
151
|
+
# the parent's folder (no cwd: given): the child still starts.
|
|
152
|
+
[child.id, [child.model_warning, other_checkout(task, folder, parent: parent, child_id: child.id,
|
|
153
|
+
no_cwd: cwd.empty?)].compact]
|
|
146
154
|
end
|
|
147
155
|
private_class_method :start_child
|
|
148
156
|
|
|
157
|
+
# A new child with no cwd: its task may name another checkout of this
|
|
158
|
+
# repository (a worktree the parent meant to pass as cwd:), which makes
|
|
159
|
+
# every write there an ask for the user (child-boundary). One warning
|
|
160
|
+
# line, however many are named; nil when the task names none, or names
|
|
161
|
+
# only the checkout holding the child's folder (or a subfolder of it).
|
|
162
|
+
# Never raises: a started child must not come back as an Error:.
|
|
163
|
+
# @return [String, nil]
|
|
164
|
+
def self.other_checkout(task, folder, parent:, child_id:, no_cwd:)
|
|
165
|
+
return nil unless no_cwd
|
|
166
|
+
|
|
167
|
+
checkout = foreign_checkout(task, folder)
|
|
168
|
+
return nil unless checkout
|
|
169
|
+
|
|
170
|
+
Log.info(:worker, "delegate_other_checkout", parent: parent.id[0, 8])
|
|
171
|
+
"this child runs in #{folder}, but its task names #{checkout}. If it should work there, stop it " \
|
|
172
|
+
"(chi sessions stop #{child_id}) and call delegate again without session, with cwd: #{checkout}."
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
private_class_method :other_checkout
|
|
177
|
+
|
|
178
|
+
# The checkout of +folder+'s repository that the task names and that is
|
|
179
|
+
# not the one holding +folder+: the first path token in the task that
|
|
180
|
+
# belongs to one (the deepest containing it), else nil. Reads files
|
|
181
|
+
# only, no git process, so a spec stubbing Process.spawn is unaffected.
|
|
182
|
+
# @return [String, nil] an absolute checkout folder
|
|
183
|
+
def self.foreign_checkout(task, folder)
|
|
184
|
+
common = MemoryPaths.common_git_dir(folder)
|
|
185
|
+
return nil unless common
|
|
186
|
+
|
|
187
|
+
checkouts = Guardrails::GitInfo.checkouts(common).map { |checkout| File.realpath(checkout) }
|
|
188
|
+
own = deepest_checkout(File.realpath(folder), checkouts)
|
|
189
|
+
task.to_s.scan(PATH_TOKEN) do |token|
|
|
190
|
+
path = existing_ancestor(File.expand_path(token.sub(/[.:!?]+\z/, ""), folder))
|
|
191
|
+
next if path.nil?
|
|
192
|
+
|
|
193
|
+
found = deepest_checkout(path, checkouts)
|
|
194
|
+
return found if found && found != own
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
private_class_method :foreign_checkout
|
|
199
|
+
|
|
200
|
+
# The checkout containing +path+, matched by whole components (so
|
|
201
|
+
# /x/app is not found in /x/app-fix): the deepest, so a nested worktree's
|
|
202
|
+
# path belongs to that worktree, not to main; nil when none does.
|
|
203
|
+
def self.deepest_checkout(path, checkouts)
|
|
204
|
+
checkouts.select { |checkout| within?(path, checkout) }.max_by { |checkout| checkout.count("/") }
|
|
205
|
+
end
|
|
206
|
+
private_class_method :deepest_checkout
|
|
207
|
+
|
|
208
|
+
# +path+'s longest ancestor that exists, realpathed (a worktree not made
|
|
209
|
+
# yet still points at its parent folder); nil when nothing above it does.
|
|
210
|
+
def self.existing_ancestor(path)
|
|
211
|
+
dir = path
|
|
212
|
+
until File.exist?(dir)
|
|
213
|
+
parent = File.dirname(dir)
|
|
214
|
+
return nil if parent == dir
|
|
215
|
+
|
|
216
|
+
dir = parent
|
|
217
|
+
end
|
|
218
|
+
File.realpath(dir)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
private_class_method :existing_ancestor
|
|
223
|
+
|
|
149
224
|
# The Error: line for a toolkit name the parent doesn't know
|
|
150
225
|
# (Peers#toolkit_names: its built-in, config and plugin kits), nil
|
|
151
226
|
# when it knows it, none was given, or the peers can't tell (a bare
|
|
@@ -126,8 +126,7 @@ module Samagotchi
|
|
|
126
126
|
# @param env [Hash] the XDG env the memories dir resolves from
|
|
127
127
|
def self.scoped_index(scope, env: ENV)
|
|
128
128
|
dir = memories_dir(scope, env: env)
|
|
129
|
-
|
|
130
|
-
return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
|
|
129
|
+
return read_text(File.join(dir, "#{MEMORY_INDEX}.md")) if File.file?(File.join(dir, "#{MEMORY_INDEX}.md"))
|
|
131
130
|
|
|
132
131
|
files = Dir.glob(File.join(dir, "*.md")).sort
|
|
133
132
|
return "No memories stored yet." if files.empty?
|
|
@@ -135,6 +134,17 @@ module Samagotchi
|
|
|
135
134
|
"Stored memories (no index yet):\n" + files.map { |f| File.basename(f, ".md") }.join("\n")
|
|
136
135
|
end
|
|
137
136
|
|
|
137
|
+
# A text file from a memories dir, safe to match: a hand-edited index
|
|
138
|
+
# can hold a byte that isn't valid UTF-8, and a regex on it raises.
|
|
139
|
+
def self.read_text(path)
|
|
140
|
+
return "" unless File.file?(path)
|
|
141
|
+
|
|
142
|
+
text = File.read(path, encoding: "UTF-8")
|
|
143
|
+
text.valid_encoding? ? text : text.scrub
|
|
144
|
+
rescue StandardError
|
|
145
|
+
""
|
|
146
|
+
end
|
|
147
|
+
|
|
138
148
|
def self.memory_path(entry_name, scope)
|
|
139
149
|
File.join(memories_dir(scope), "#{entry_name}.md")
|
|
140
150
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1018,7 +1018,7 @@ module Samagotchi
|
|
|
1018
1018
|
def handle_info(req)
|
|
1019
1019
|
return participant_info unless principal(req).owner?
|
|
1020
1020
|
|
|
1021
|
-
chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle" }
|
|
1021
|
+
chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle", auto_offers: nil }
|
|
1022
1022
|
json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
|
|
1023
1023
|
cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
|
|
1024
1024
|
proxy: @public_url && { identity_header: @public_url.identity_header } })
|
|
@@ -1099,7 +1099,7 @@ module Samagotchi
|
|
|
1099
1099
|
|
|
1100
1100
|
# Kits the web never offers: the participants' boundary, a delegate
|
|
1101
1101
|
# child's, and ToolkitSuggest's defer sentinel.
|
|
1102
|
-
PICKER_HIDDEN_KITS = [Toolkit::REVIEW, Toolkit::CHILD,
|
|
1102
|
+
PICKER_HIDDEN_KITS = [Toolkit::REVIEW, Toolkit::CHILD, ToolkitSuggest::UNDECIDED].freeze
|
|
1103
1103
|
|
|
1104
1104
|
# The start page's toolkit picker: {default:, suggest:, kits: [{name,
|
|
1105
1105
|
# description?}]}. Built-in and config kits only (a bundle's load in
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rubygems/version"
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "../config"
|
|
6
|
+
require_relative "../installed_versions"
|
|
6
7
|
require_relative "../log"
|
|
7
8
|
require_relative "../version"
|
|
8
9
|
require_relative "auto_update_record"
|
|
@@ -37,6 +38,17 @@ module Samagotchi
|
|
|
37
38
|
# A preflight that fails leaves chi web as it is and says so.
|
|
38
39
|
# update.restart: never stops after the install.
|
|
39
40
|
#
|
|
41
|
+
# A newer chi installed some other way (a terminal's `chi update`, inside
|
|
42
|
+
# update.min_age_hours or not) is adopted: once it has settled and the
|
|
43
|
+
# gate is open, it gets a run from step 1 on. One run per version here
|
|
44
|
+
# too, so a restart that didn't take isn't tried again.
|
|
45
|
+
#
|
|
46
|
+
# The page offers no restart buttons for a run that will do them itself.
|
|
47
|
+
# It offers them again when this says so (SessionHub#auto_offer_restart):
|
|
48
|
+
# a run on record for the version ended with chi web still older, or
|
|
49
|
+
# something held it for HELD_FOR, a forgotten question or a long turn.
|
|
50
|
+
# The trying goes on behind that offer.
|
|
51
|
+
#
|
|
40
52
|
# The run's record (AutoUpdateRecord) lives in update_check.json
|
|
41
53
|
# (UpdateCheck#save_auto), so a chi web restart finds it. See
|
|
42
54
|
# docs/architecture.md.
|
|
@@ -56,6 +68,13 @@ module Samagotchi
|
|
|
56
68
|
OPEN_PHASES = %w[installed workers web].freeze
|
|
57
69
|
# The phase after "chi updated" while workers are left.
|
|
58
70
|
RETRYING = "retrying"
|
|
71
|
+
# A newer chi seen installed is adopted only this long after: `chi update`
|
|
72
|
+
# installs the gem first and merges the bundles after, with no lock or
|
|
73
|
+
# marker to look at. In practice the next POLL.
|
|
74
|
+
SETTLE = 60
|
|
75
|
+
# How long a newer installed chi may wait for its restart before the page
|
|
76
|
+
# offers the buttons again.
|
|
77
|
+
HELD_FOR = 3600
|
|
59
78
|
# How long stop waits for the thread: long enough for a preflight
|
|
60
79
|
# (Server::PREFLIGHT_TIMEOUT, 20 s; a healthy one takes about a
|
|
61
80
|
# second) to end on its own, so a stop never abandons one mid-way.
|
|
@@ -84,6 +103,7 @@ module Samagotchi
|
|
|
84
103
|
@wakeup = Queue.new
|
|
85
104
|
@thread = nil
|
|
86
105
|
@stopped = false
|
|
106
|
+
@seen = nil
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
def start
|
|
@@ -108,17 +128,17 @@ module Samagotchi
|
|
|
108
128
|
def step
|
|
109
129
|
return unless @auto == "install"
|
|
110
130
|
|
|
131
|
+
note_installed
|
|
111
132
|
record = @check.auto_state
|
|
112
133
|
return carry_on(record) if record && OPEN_PHASES.include?(record.phase)
|
|
113
134
|
|
|
114
135
|
retry_left(record) if record&.phase == RETRYING
|
|
136
|
+
return if @ops.blocked_reason || @ops.running? || busy_turn?
|
|
115
137
|
|
|
116
138
|
version = @check.available
|
|
117
|
-
return
|
|
118
|
-
return if @check.auto_failed?(version) || tried?(version)
|
|
119
|
-
return if @ops.blocked_reason || @ops.running? || busy_turn?
|
|
139
|
+
return install(version) if version && !@check.auto_failed?(version) && !tried?(version)
|
|
120
140
|
|
|
121
|
-
|
|
141
|
+
adopt_installed
|
|
122
142
|
end
|
|
123
143
|
|
|
124
144
|
private
|
|
@@ -158,6 +178,37 @@ module Samagotchi
|
|
|
158
178
|
end
|
|
159
179
|
end
|
|
160
180
|
|
|
181
|
+
# What is installed, ahead of every gate: since when a newer chi is, and
|
|
182
|
+
# whether the page should offer the restarts for it. A chi web restart
|
|
183
|
+
# starts the clock over, and finds a closed run's offer again here.
|
|
184
|
+
def note_installed
|
|
185
|
+
installed = @hub.refresh_installed
|
|
186
|
+
return @seen = nil unless InstalledVersions.newer?(installed)
|
|
187
|
+
|
|
188
|
+
now = @clock.call
|
|
189
|
+
@seen = [installed, now] unless @seen&.first == installed
|
|
190
|
+
@hub.auto_offer_restart(installed) if closed_run?(installed) || now - @seen.last >= HELD_FOR
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A run on record for +version+ that is over, with chi web still older.
|
|
194
|
+
def closed_run?(version)
|
|
195
|
+
record = @check.auto_state
|
|
196
|
+
!record.nil? && record.version == version && !OPEN_PHASES.include?(record.phase)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A newer chi this didn't install, settled: a run of its own from here.
|
|
200
|
+
# The terminal's table already told what `chi update` kept.
|
|
201
|
+
def adopt_installed
|
|
202
|
+
return unless @restart == "idle" && @seen
|
|
203
|
+
|
|
204
|
+
installed, seen_at = @seen
|
|
205
|
+
now = @clock.call
|
|
206
|
+
return if tried?(installed) || now - seen_at < SETTLE
|
|
207
|
+
|
|
208
|
+
Log.info(:web, "auto_update_adopted", version: installed, from: VERSION)
|
|
209
|
+
carry_on(save(AutoUpdateRecord.new(version: installed, from: VERSION, at: now.iso8601, phase: "installed")))
|
|
210
|
+
end
|
|
211
|
+
|
|
161
212
|
def install(version)
|
|
162
213
|
Log.info(:web, "auto_update_start", version: version, from: VERSION)
|
|
163
214
|
result = @ops.install(version, wait: true, auto: true)
|
|
@@ -187,7 +238,9 @@ module Samagotchi
|
|
|
187
238
|
# The next step of a run on record.
|
|
188
239
|
def carry_on(record)
|
|
189
240
|
case record.phase
|
|
190
|
-
when "installed", "workers"
|
|
241
|
+
when "installed", "workers"
|
|
242
|
+
# Restarted by hand on the way (the page's offer): nothing left to exec.
|
|
243
|
+
InstalledVersions.newer?(record.version) ? workers_then_web(record) : finish(record)
|
|
191
244
|
when "web" then finish(record)
|
|
192
245
|
end
|
|
193
246
|
end
|
|
@@ -260,6 +313,7 @@ module Samagotchi
|
|
|
260
313
|
def restart_failed(record, detail, event)
|
|
261
314
|
Log.warn(:web, event, version: record.version, detail: detail)
|
|
262
315
|
save(record.with(phase: "failed", detail: detail, pushed: true))
|
|
316
|
+
@hub.auto_offer_restart(record.version)
|
|
263
317
|
@hub.publish_notice(kind: "restart_failed", version: record.version, detail: detail)
|
|
264
318
|
end
|
|
265
319
|
|
|
@@ -307,6 +361,7 @@ module Samagotchi
|
|
|
307
361
|
Log.warn(:web, "auto_update_failed_install", version: record.version, detail: detail)
|
|
308
362
|
gem_installed = rows.any? { |candidate| candidate["component"] == "chi (gem)" && !candidate["failed"] }
|
|
309
363
|
@check.save_auto(record.with(phase: "failed", detail: detail, pushed: true), failed: record.version)
|
|
364
|
+
@hub.auto_offer_restart(record.version)
|
|
310
365
|
@hub.publish_notice(kind: "failed", version: record.version, detail: detail.empty? ? nil : detail,
|
|
311
366
|
installed: gem_installed)
|
|
312
367
|
end
|
|
@@ -95,7 +95,7 @@ import { waitingOn } from "./notify.js";
|
|
|
95
95
|
import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
|
|
96
96
|
import { createNotifications } from "./notifications.js";
|
|
97
97
|
import { renameShown, tabTitle, topBarTitle } from "./session_title.js";
|
|
98
|
-
import { mergeVersionState, restartConfirmText, versionNotice } from "./update_notice.js";
|
|
98
|
+
import { mergeVersionState, restartConfirmText, updateFrameToast, versionNotice } from "./update_notice.js";
|
|
99
99
|
import {
|
|
100
100
|
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
101
101
|
} from "./timing.js";
|
|
@@ -433,6 +433,8 @@ const questionCards = createQuestionCards({
|
|
|
433
433
|
renderMarkdown,
|
|
434
434
|
linkRefs: linkRefsIn,
|
|
435
435
|
pairRefs: pairRefsIn,
|
|
436
|
+
// The bar's chip reads questionCards.pendingQuestion() (sessionWaits).
|
|
437
|
+
onPending: () => store.touch("infoBar"),
|
|
436
438
|
});
|
|
437
439
|
// The history's bubbles (bubbles.js): output, end lines, every client's
|
|
438
440
|
// prompts, the recap, warnings and notices, command and note bubbles.
|
|
@@ -583,7 +585,7 @@ function noticeVersions(data) {
|
|
|
583
585
|
const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
|
|
584
586
|
installed: versions.installed, available: versions.available,
|
|
585
587
|
updateBlocked: versions.updateBlocked, auto: versions.auto, restart: versions.restart,
|
|
586
|
-
|
|
588
|
+
autoOffers: versions.autoOffers, shown: versionNoticesShown });
|
|
587
589
|
store.touch("infoBar"); // the worker badge compares with the newest installed
|
|
588
590
|
if (!notice) return;
|
|
589
591
|
versionNoticesShown.add(notice.key);
|
|
@@ -612,20 +614,9 @@ function noticeVersions(data) {
|
|
|
612
614
|
function updateFrame(data, { snapshot = false } = {}) {
|
|
613
615
|
const wasOpen = updateSheet.isOpen();
|
|
614
616
|
if (!updateSheet.receive(data)) return;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (data.operation === "install") {
|
|
619
|
-
noticeVersions({ ...(data.state === "done" && data.version ? { installed: data.version } : {}), chi_update: data });
|
|
620
|
-
}
|
|
621
|
-
const label = `${data.auto ? "Automatic " : ""}${data.operation === "install" ? "chi update" : "worker restart"}`
|
|
622
|
-
.replace(/^./, (c) => c.toUpperCase());
|
|
623
|
-
if (!wasOpen && data.state === "running") {
|
|
624
|
-
showToast(`${label} in progress`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
625
|
-
} else if (!snapshot && !wasOpen && ["done", "failed"].includes(data.state)
|
|
626
|
-
&& !(data.operation === "install" && data.state === "done")) {
|
|
627
|
-
showToast(`${label} ${data.state}`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
628
|
-
}
|
|
617
|
+
if (data.operation === "install" && data.state === "done" && data.version) noticeVersions({ installed: data.version });
|
|
618
|
+
const text = updateFrameToast(data, { wasOpen, snapshot });
|
|
619
|
+
if (text) showToast(text, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
629
620
|
}
|
|
630
621
|
|
|
631
622
|
function copyUpdateCommand(command) {
|
|
@@ -1508,9 +1499,11 @@ async function restartWorker() {
|
|
|
1508
1499
|
|
|
1509
1500
|
// The session bar (info_bar_model.js sessionBar): its buttons, and the
|
|
1510
1501
|
// info line rendered by info_bar.js.
|
|
1511
|
-
// The open session waits on the user: its
|
|
1502
|
+
// The open session waits on the user: its card is up, or its summary says so
|
|
1503
|
+
// (not for a question answered here: the summary is a refresh behind).
|
|
1512
1504
|
function sessionWaits() {
|
|
1513
|
-
|
|
1505
|
+
const summary = waitingOn(store.sessions.find((s) => s.id === store.selected));
|
|
1506
|
+
return !!((summary && !questionCards.isResolved(summary.id)) || questionCards.pendingQuestion());
|
|
1514
1507
|
}
|
|
1515
1508
|
function updateInfoBar() {
|
|
1516
1509
|
annotateBar?.refresh();
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// blank the page.
|
|
19
19
|
|
|
20
20
|
import { quoteBlock } from "./annotations.js";
|
|
21
|
+
import { isParticipantAuthor } from "./participant_model.js";
|
|
21
22
|
|
|
22
23
|
// Where a node on the participant page came from: { kind, root, … } where
|
|
23
24
|
// root is the element a selection is clipped to, or null.
|
|
@@ -69,7 +70,7 @@ export function participantQuote(text, source) {
|
|
|
69
70
|
// participant route's: mine, author, author_name, automatic).
|
|
70
71
|
export function messageAuthorData(message) {
|
|
71
72
|
if (message?.mine) return { by: "me" };
|
|
72
|
-
if (message?.author) return { by: "participant", name: message.author_name || "" };
|
|
73
|
+
if (isParticipantAuthor(message?.author)) return { by: "participant", name: message.author_name || "" };
|
|
73
74
|
if (message?.automatic) return { by: "automatic" };
|
|
74
75
|
return { by: "owner" };
|
|
75
76
|
}
|
|
@@ -41,13 +41,18 @@ import { plainText } from "./plain_line.js";
|
|
|
41
41
|
// own text; markdown_place.js pairRefsIn)
|
|
42
42
|
// @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
|
|
43
43
|
// @param doc the page's document
|
|
44
|
+
// @param onPending (pending|null) called whenever the open question changes:
|
|
45
|
+
// the page touches the redraws that read pendingQuestion() (the session
|
|
46
|
+
// bar's waiting chip, docs/internals/web-frontend.md write -> redraw)
|
|
44
47
|
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
45
48
|
// questionCard() (the live card or null), pendingQuestion() (or null),
|
|
49
|
+
// isResolved(id) (this page already saw that question answered),
|
|
46
50
|
// renderContinue(offer), resolveContinue(data), forgetContinue()}
|
|
47
51
|
export function createQuestionCards({
|
|
48
52
|
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
49
53
|
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
|
|
50
54
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
55
|
+
onPending = () => {},
|
|
51
56
|
}) {
|
|
52
57
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
53
58
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -57,6 +62,16 @@ export function createQuestionCards({
|
|
|
57
62
|
let continueEl = null;
|
|
58
63
|
// card → the reply that asked it, drawn under the summary once resolved.
|
|
59
64
|
const saidOf = new WeakMap();
|
|
65
|
+
// The question last answered here: a session summary fetched earlier still
|
|
66
|
+
// names it as pending until the next refresh (isResolved).
|
|
67
|
+
let resolvedId = null;
|
|
68
|
+
|
|
69
|
+
// The one way the open question changes: +onPending+ is the page's hook for
|
|
70
|
+
// the redraws that read it (the session bar's waiting chip).
|
|
71
|
+
function setPending(next) {
|
|
72
|
+
pending = next;
|
|
73
|
+
onPending(pending);
|
|
74
|
+
}
|
|
60
75
|
|
|
61
76
|
// +said+: the asking reply when the caller knows it (a reloaded card: its
|
|
62
77
|
// step's text, "" for none); else the running turn's (askingText).
|
|
@@ -65,8 +80,8 @@ export function createQuestionCards({
|
|
|
65
80
|
if (action === "same") return;
|
|
66
81
|
// A resolved card stays where it was; only a pending one is replaced.
|
|
67
82
|
if (action === "keep") questionEl = null;
|
|
68
|
-
else
|
|
69
|
-
|
|
83
|
+
else replaceQuestionCard();
|
|
84
|
+
setPending(pq);
|
|
70
85
|
removeHintIfEmpty();
|
|
71
86
|
|
|
72
87
|
const approval = approvalView(pq);
|
|
@@ -424,7 +439,7 @@ export function createQuestionCards({
|
|
|
424
439
|
// The parent relayed the open question, or no longer does (question_relay).
|
|
425
440
|
function markRelayed(id, relayedTo) {
|
|
426
441
|
if (!questionEl || !pending || String(pending.id) !== String(id)) return;
|
|
427
|
-
|
|
442
|
+
setPending({ ...pending, relayed_to: relayedTo || null });
|
|
428
443
|
questionEl.querySelector(".question-relayed")?.replaceWith(renderRelayedTo(pending));
|
|
429
444
|
}
|
|
430
445
|
|
|
@@ -491,7 +506,8 @@ export function createQuestionCards({
|
|
|
491
506
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
492
507
|
// After the collapse: a card left open by hand renders at once.
|
|
493
508
|
if (full) renderSaidOnOpen(questionEl, full, asked, refs);
|
|
494
|
-
|
|
509
|
+
resolvedId = pending.id;
|
|
510
|
+
setPending(null);
|
|
495
511
|
}
|
|
496
512
|
|
|
497
513
|
// The body's copy as markdown, from the card's first open on (a reload
|
|
@@ -546,11 +562,18 @@ export function createQuestionCards({
|
|
|
546
562
|
}
|
|
547
563
|
|
|
548
564
|
function removeQuestionCard() {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
565
|
+
replaceQuestionCard();
|
|
566
|
+
setPending(null);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// The live card taken off the history because a new one takes its place:
|
|
570
|
+
// the question itself changes in the same step, so the hook is not touched
|
|
571
|
+
// for the in-between null.
|
|
572
|
+
function replaceQuestionCard() {
|
|
573
|
+
if (!questionEl) return;
|
|
574
|
+
|
|
575
|
+
questionEl.remove();
|
|
576
|
+
questionEl = null;
|
|
554
577
|
}
|
|
555
578
|
|
|
556
579
|
// ── continue card ──────────────────────────────────────────────────────────
|
|
@@ -653,6 +676,7 @@ export function createQuestionCards({
|
|
|
653
676
|
removeQuestion: removeQuestionCard,
|
|
654
677
|
questionCard: () => questionEl,
|
|
655
678
|
pendingQuestion: () => pending,
|
|
679
|
+
isResolved: (id) => resolvedId !== null && String(resolvedId) === String(id),
|
|
656
680
|
renderContinue: renderContinueCard,
|
|
657
681
|
resolveContinue: resolveContinueCard,
|
|
658
682
|
// The running turn's DOM was reset: a later offer draws a new card.
|
|
@@ -30,6 +30,7 @@ import { bubbleCopyText } from "./copy.js";
|
|
|
30
30
|
import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
|
|
31
31
|
import { clip } from "./format.js";
|
|
32
32
|
import { NOTE_BOUNDARY } from "./turn_range.js";
|
|
33
|
+
import { workerPredates } from "./worker_features.js";
|
|
33
34
|
import { SWAPPED_CLASS, VIEW_CLASS, stepVersion, switcherTemplate, viewOf } from "./revise_switch.js";
|
|
34
35
|
|
|
35
36
|
// The classes and attributes this module puts on the history.
|
|
@@ -113,12 +114,12 @@ export function versionChipText(count, at = count - 1) {
|
|
|
113
114
|
// the worker predates Revise turns).
|
|
114
115
|
export const REVISE_UNAVAILABLE = "restart this session to use Revise";
|
|
115
116
|
|
|
116
|
-
// The feature gate (app.js featureGates): a live worker without the
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
117
|
+
// The feature gate (app.js featureGates): a live worker without the "revise"
|
|
118
|
+
// feature predates Revise turns (chi web would answer 409 revise_unavailable).
|
|
119
|
+
// A worker that isn't up isn't asked: the send wakes a new one, which has it
|
|
120
|
+
// (worker_features.js's rule, the same as Verify's gate).
|
|
120
121
|
export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
|
|
121
|
-
return
|
|
122
|
+
return workerPredates(workerFeatures, bridgeUp, "revise");
|
|
122
123
|
}
|
|
123
124
|
export function reviseChipText(label, { unavailable = false } = {}) {
|
|
124
125
|
if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
|
|
@@ -17,12 +17,7 @@ export function mergeVersionState(current = {}, frame = {}) {
|
|
|
17
17
|
if (has(frame, "update_blocked")) next.updateBlocked = known(frame.update_blocked) ? frame.update_blocked : null;
|
|
18
18
|
if (has(frame, "auto")) next.auto = frame.auto === "install" ? "install" : "off";
|
|
19
19
|
if (has(frame, "restart")) next.restart = frame.restart === "never" ? "never" : "idle";
|
|
20
|
-
|
|
21
|
-
// version an automatic one installs, while it hasn't failed.
|
|
22
|
-
const run = frame.chi_update;
|
|
23
|
-
if (run && run.operation === "install" && known(run.version)) {
|
|
24
|
-
next.autoInstall = run.auto === true && run.state !== "failed" ? run.version : null;
|
|
25
|
-
}
|
|
20
|
+
if (has(frame, "auto_offers")) next.autoOffers = known(frame.auto_offers) ? frame.auto_offers : null;
|
|
26
21
|
return next;
|
|
27
22
|
}
|
|
28
23
|
|
|
@@ -53,22 +48,23 @@ export function newerVersion(a, b) {
|
|
|
53
48
|
// The toast for the versions as the page knows them, or null:
|
|
54
49
|
// - loaded ≠ served: chi web restarted on another version; reload the page;
|
|
55
50
|
// - installed newer than served: chi web runs an older chi than installed;
|
|
56
|
-
// restart its workers or chi web; not
|
|
57
|
-
//
|
|
58
|
-
//
|
|
51
|
+
// restart its workers or chi web; not with update.auto: install and
|
|
52
|
+
// update.restart: idle (and not blocked), where chi web restarts them by
|
|
53
|
+
// itself and pushes how it went, however that chi got installed, unless
|
|
54
|
+
// chi web says it won't or has been held for long (+autoOffers+);
|
|
59
55
|
// - available newer than served: install it, unless the install is blocked;
|
|
60
56
|
// with update.auto: install (and not blocked) chi web installs it by itself
|
|
61
57
|
// once no turn runs: the notice says so, with no Update button.
|
|
62
58
|
// Each has a key; one in +shown+ (a Set: what this tab already showed, as
|
|
63
59
|
// every reconnect's snapshot says it again) is not shown twice.
|
|
64
60
|
// @return {{key, text, reload: boolean} | null}
|
|
65
|
-
export function versionNotice({ loaded, served, installed, available, updateBlocked, auto, restart,
|
|
61
|
+
export function versionNotice({ loaded, served, installed, available, updateBlocked, auto, restart, autoOffers,
|
|
66
62
|
shown = new Set() } = {}) {
|
|
67
63
|
let notice = null;
|
|
68
64
|
if (known(loaded) && known(served) && loaded !== served) {
|
|
69
65
|
notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
|
|
70
66
|
} else if (newerVersion(installed, served)) {
|
|
71
|
-
const restartsByItself = auto === "install" && restart !== "never" &&
|
|
67
|
+
const restartsByItself = auto === "install" && restart !== "never" && !updateBlocked && autoOffers !== installed;
|
|
72
68
|
if (!restartsByItself) {
|
|
73
69
|
notice = {
|
|
74
70
|
key: `installed:${installed}`,
|
|
@@ -101,6 +97,21 @@ export function versionNotice({ loaded, served, installed, available, updateBloc
|
|
|
101
97
|
return notice && !shown.has(notice.key) ? notice : null;
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
// The toast's words for an update operation's frame, or null: what runs and how
|
|
101
|
+
// it ended, for a tab whose sheet isn't open. An automatic run's worker passes
|
|
102
|
+
// say something only when one fails (the run ends in the Reload toast); a done
|
|
103
|
+
// install leaves it to the version toast; a snapshot repeats no ending.
|
|
104
|
+
export function updateFrameToast(data, { wasOpen = false, snapshot = false } = {}) {
|
|
105
|
+
if (wasOpen) return null;
|
|
106
|
+
if (data.auto && data.operation === "workers" && data.state !== "failed") return null;
|
|
107
|
+
const label = `${data.auto ? "Automatic " : ""}${data.operation === "install" ? "chi update" : "worker restart"}`
|
|
108
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
109
|
+
if (data.state === "running") return `${label} in progress`;
|
|
110
|
+
if (snapshot || !["done", "failed"].includes(data.state)) return null;
|
|
111
|
+
if (data.operation === "install" && data.state === "done") return null;
|
|
112
|
+
return `${label} ${data.state}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
104
115
|
// The info bar's badge for the open session's worker when it runs an older
|
|
105
116
|
// chi than the newest installed (or than chi web, while that is unknown),
|
|
106
117
|
// else null. A worker with the "restart" feature offers Restart; an older
|