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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/docs/architecture.md +14 -5
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +16 -3
  6. data/docs/guardrails.md +7 -2
  7. data/docs/internals/bridge.md +3 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/web-frontend.md +3 -2
  10. data/docs/memory.md +45 -0
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +13 -6
  14. data/lib/samagotchi/guardrails/approval.rb +17 -4
  15. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
  16. data/lib/samagotchi/guardrails/verdict.rb +5 -3
  17. data/lib/samagotchi/memory_command.rb +135 -7
  18. data/lib/samagotchi/memory_paths.rb +9 -0
  19. data/lib/samagotchi/memory_usage.rb +368 -0
  20. data/lib/samagotchi/relay.rb +2 -2
  21. data/lib/samagotchi/tool_activity.rb +3 -3
  22. data/lib/samagotchi/tool_output_cut.rb +12 -1
  23. data/lib/samagotchi/toolkit_suggest.rb +7 -4
  24. data/lib/samagotchi/tools/delegate.rb +88 -13
  25. data/lib/samagotchi/tools/memory.rb +12 -2
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/app.rb +2 -2
  28. data/lib/samagotchi/web/auto_update.rb +60 -5
  29. data/lib/samagotchi/web/public/app.js +11 -18
  30. data/lib/samagotchi/web/public/participant_annotate.js +2 -1
  31. data/lib/samagotchi/web/public/question_cards.js +33 -9
  32. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  33. data/lib/samagotchi/web/public/update_notice.js +22 -11
  34. data/lib/samagotchi/web/public/update_sheet.js +4 -1
  35. data/lib/samagotchi/web/public/verify_preset.js +5 -1
  36. data/lib/samagotchi/web/public/worker_features.js +14 -0
  37. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  38. data/lib/samagotchi/web/session_hub.rb +17 -4
  39. data/lib/samagotchi/working_set.rb +3 -2
  40. 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, "undecided"].include?(kit.name)
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["undecided"] = "There is no task yet; defer this choice"
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) == "undecided"
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 == "undecided"
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, warning = if session.to_s.strip.empty?
64
- start_child(task, parent: parent, model: model, cwd: cwd, toolkit: toolkit, state_dir: sd,
65
- peers: peers)
66
- elsif !cwd.empty?
67
- "Error: cwd starts a new child; a follow-up with session keeps the child's folder"
68
- elsif !toolkit.empty?
69
- "Error: toolkit starts a new child; a follow-up keeps the child's toolkit"
70
- else
71
- follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
72
- end
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 ? "Warning: #{warning}\n" : ""
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
- [child.id, child.model_warning]
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
- index_path = File.join(dir, "#{MEMORY_INDEX}.md")
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.145.0"
4
+ VERSION = "0.147.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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, "undecided"].freeze
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 unless version
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
- install(version)
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" then workers_then_web(record)
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
- autoInstall: versions.autoInstall, shown: versionNoticesShown });
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
- // Every install frame, not only the done one: the hub's `chi` frame with
616
- // the new installed version comes before it, and an automatic run's
617
- // running frame is what keeps that from offering the Restart toast.
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 summary says so, or its card is up.
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
- return !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion());
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 removeQuestionCard();
69
- pending = pq;
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
- pending = { ...pending, relayed_to: relayedTo || null };
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
- pending = null;
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
- if (questionEl) {
550
- questionEl.remove();
551
- questionEl = null;
552
- }
553
- pending = null;
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
- // "revise" feature predates Revise turns (chi web would answer 409
118
- // revise_unavailable). A worker that isn't up isn't asked: the send wakes a
119
- // new one, which has it (as Verify's gate, verify_preset.js).
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 bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("revise");
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
- // The last install's chi_update state (a snapshot's, or a frame's): the
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 for the version an automatic install
57
- // (+autoInstall+) brought with update.restart: idle, whose run restarts
58
- // them by itself (and pushes how it went);
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, autoInstall,
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" && autoInstall === installed;
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