samagotchi 0.127.0 → 0.129.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/docs/architecture.md +15 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +155 -25
  6. data/docs/internals/participants.md +23 -5
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bridge_client.rb +8 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +56 -0
  14. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  15. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  16. data/lib/samagotchi/guardrails/outside.rb +54 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  18. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  20. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  21. data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
  22. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  23. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  24. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  25. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  26. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  28. data/lib/samagotchi/guardrails/targets.rb +128 -24
  29. data/lib/samagotchi/session_manager.rb +13 -14
  30. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  31. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  33. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  35. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  36. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  38. data/lib/samagotchi/verify_anchor.rb +8 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/app.rb +9 -7
  41. data/lib/samagotchi/web/participant_review.rb +4 -2
  42. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  43. data/lib/samagotchi/web/public/app.js +25 -5
  44. data/lib/samagotchi/web/public/composer.css +1 -0
  45. data/lib/samagotchi/web/public/notify.js +2 -1
  46. data/lib/samagotchi/web/public/participant.html +3 -2
  47. data/lib/samagotchi/web/public/participant.js +18 -7
  48. data/lib/samagotchi/web/public/participant_model.js +3 -2
  49. data/lib/samagotchi/web/public/participant_names.js +2 -2
  50. data/lib/samagotchi/web/public/question_card.js +4 -1
  51. data/lib/samagotchi/web/public/session_state.js +2 -0
  52. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  53. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  54. data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
  55. data/lib/samagotchi/web/public/verify_card.css +1 -0
  56. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  57. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  58. data/lib/samagotchi/worker.rb +82 -32
  59. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  60. data/lib/samagotchi/worker_link.rb +13 -5
  61. metadata +2 -1
@@ -71,7 +71,8 @@ module Samagotchi
71
71
  # TUI's streams and requests there (#client_id)
72
72
  # @return [Symbol] :detached, :closed when the worker went away,
73
73
  # :failed when the --model switch didn't go through, or (input from a
74
- # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
74
+ # pipe) :turn_failed / :empty_answer / :removed / :unanswered
75
+ # (AttachedLoop#run)
75
76
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
76
77
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
77
78
  toolkit_flags: [], settings: nil, parent_answers: nil, state_dir: nil, connector: nil)
@@ -146,11 +147,14 @@ module Samagotchi
146
147
  err.flush
147
148
  end
148
149
 
149
- # A turn of ours that ended with no answer (input from a pipe): one
150
- # line on stderr, as the in-process `chi -p --non-interactive` says
151
- # it, so a script doesn't take the silence for an answer (exit 1).
150
+ # An own turn with no answer, or a prompt removed before it ran
151
+ # (input from a pipe): one line on stderr, so a script doesn't take
152
+ # the silence for an answer (exit 1).
152
153
  def report_ended(ended, err: $stderr)
153
- err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
154
+ case ended
155
+ when :empty_answer then err.puts(TerminalUI::EMPTY_ANSWER_ERROR)
156
+ when :removed then err.puts("chi: the prompt was removed before it ran")
157
+ end
154
158
  end
155
159
 
156
160
  # chi's exit status after #run: 0 detached, 3 a question left waiting
@@ -7,8 +7,8 @@ module Samagotchi
7
7
  class AttachedLoop
8
8
  # Queue and unqueue handling for the attached loop.
9
9
  # Reads @client, @client_id, @first_command_id, @link, @my_queued,
10
- # @open_ids, @reader, @screen, @shown_enqueued, @wait_at_eof; writes
11
- # @my_queued, @open_ids, @own_empty, @own_failed, @prefilled, @queued_ids,
10
+ # @own_turns, @reader, @screen, @shown_enqueued, @wait_at_eof; writes
11
+ # @my_queued, @prefilled, @queued_ids,
12
12
  # @shown_enqueued.
13
13
  # Calls the loop helpers #author_of, #first_command_flag, #mark_activity,
14
14
  # #own?, #prompt_line, #send_prompt, #via, and #worker_down.
@@ -131,19 +131,13 @@ module Samagotchi
131
131
 
132
132
  # A queued prompt was taken back before it ran (the web's ×, /unqueue
133
133
  # in a terminal). One of this run's (wait_at_eof) will have no turn:
134
- # it ends the wait, as a failed one does. Another client's shown
135
- # line says so.
134
+ # it ends the wait as :removed. Another client's shown line says so.
136
135
  def turn_dequeued(event)
137
136
  id = event[:enqueued_id]
138
137
  @my_queued.delete(id)
139
- if @open_ids.delete?(id)
140
- if own?(event[:client_id])
141
- @own_empty = true
142
- @screen.commit("(removed before it ran)")
143
- else
144
- @own_failed = true
145
- @screen.commit("(your queued message was removed before it ran)")
146
- end
138
+ by_us = own?(event[:client_id])
139
+ if @own_turns.dequeued(id, by_us: by_us)
140
+ @screen.commit(by_us ? "(removed before it ran)" : "(your queued message was removed before it ran)")
147
141
  elsif @shown_enqueued.delete?(id)
148
142
  @screen.commit("(a queued message was removed)")
149
143
  end
@@ -10,19 +10,16 @@ require_relative "../image_input"
10
10
  module Samagotchi
11
11
  class TerminalUI
12
12
  class AttachedLoop
13
- # Bridge sending, first-command launch, and this run's turn ledger.
14
- # Reads @as_parent, @client, @client_id, @display_pending, @first_command_id,
15
- # @first_command_line, @first_commands, @first_prompt, @input_ended, @link,
16
- # @merged_ids, @my_queued, @no_interrupt, @open_ids, @own_empty, @own_failed,
17
- # @queued_ids, @reader, @running, @screen, @sent_ids, @status, @wait_at_eof;
18
- # writes @display_pending, @first_command_id, @first_command_line,
19
- # @first_commands, @first_prompt, @merged_ids, @my_queued, @open_ids,
20
- # @own_empty, @own_failed, @queued_ids, @sent_ids.
21
- # Calls the loop helpers #client_id_for, #command_registry, #default_model_name,
22
- # #detach, #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
23
- # #persist_recent_history, #prompt_line, #put_back_busy_command, #prefill_confirm,
24
- # #queue_needs_newer_worker, #send_command, #send_prompt, #stale_worker,
25
- # #steer_cut_note, #too_late?, #via, and #worker_down.
13
+ # Bridge sending and first-command launch.
14
+ # Reads @as_parent, @client, @client_id, @first_command_id, @first_command_line,
15
+ # @first_commands, @first_prompt, @link, @my_queued, @no_interrupt, @own_turns,
16
+ # @queued_ids, @reader, @running, @screen, @status;
17
+ # writes @first_command_id, @first_command_line, @first_commands, @first_prompt,
18
+ # @my_queued, @queued_ids.
19
+ # Calls the loop helpers #command_registry, #default_model_name, #detach,
20
+ # #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
21
+ # #persist_recent_history, #prompt_line, #queue_needs_newer_worker,
22
+ # #stale_worker, #steer_cut_note, #too_late?, #via, and #worker_down.
26
23
  module Sending
27
24
  private
28
25
 
@@ -33,7 +30,7 @@ module Samagotchi
33
30
  if reply.status == 202
34
31
  # Input from a pipe ends once its output is in (#command_ran).
35
32
  command_id = reply.json&.fetch("command_id", nil)
36
- @open_ids << command_id if command_id && @wait_at_eof
33
+ @own_turns.command_sent(command_id) if command_id
37
34
  return
38
35
  end
39
36
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
@@ -49,7 +46,7 @@ module Samagotchi
49
46
  end
50
47
 
51
48
  def command_ran(event)
52
- @open_ids.delete(event[:command_id])
49
+ @own_turns.command_ran(event[:command_id])
53
50
  @queued_ids.delete(event[:command_id])
54
51
  unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
55
52
  @screen.commit(prompt_line(event[:client_id], event[:line]))
@@ -147,8 +144,7 @@ module Samagotchi
147
144
  reply = @client.post_turn(**options)
148
145
  if reply.status == 202
149
146
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
150
- @sent_ids << enqueued_id if enqueued_id
151
- @open_ids << enqueued_id if enqueued_id && @wait_at_eof
147
+ @own_turns.sent(enqueued_id) if enqueued_id
152
148
  @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
153
149
  note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
154
150
  @screen.commit(note) if note
@@ -172,21 +168,15 @@ module Samagotchi
172
168
  # Whether the input's end must wait: the -p prompt isn't sent yet, a
173
169
  # prompt this run sent hasn't had its turn, or its turn's after_turn
174
170
  # hooks still run (their notices, source-links' sources:, come next).
175
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
171
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || @own_turns.pending?
176
172
 
177
173
  # A turn ended: this run's prompt (and ours merged into it) had its turn.
178
174
  def own_turn_ended(event)
179
- return unless @wait_at_eof
180
-
181
- id = origin_of(event[:origin]).enqueued_id
182
- ours = @open_ids.include?(id) || !@merged_ids.empty?
183
- @own_failed = true if ours && event[:type] == :turn_failed
184
- @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
185
- # The worker sends answer_display once its after_turn hooks ran.
186
- @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
187
- @open_ids.delete(id)
188
- @open_ids.subtract(@merged_ids)
189
- @merged_ids.clear
175
+ completed = event[:type] == :turn_completed
176
+ @own_turns.ended(origin_of(event[:origin]).enqueued_id,
177
+ failed: event[:type] == :turn_failed,
178
+ empty: completed && event.dig(:turn_summary, :empty_answer),
179
+ display_pending: completed && event[:display_pending])
190
180
  end
191
181
 
192
182
  # Ours merged into the running turn: they end with it.
@@ -194,22 +184,20 @@ module Samagotchi
194
184
  Array(event[:origins]).each do |value|
195
185
  origin = origin_of(value)
196
186
  id = origin.enqueued_id
197
- @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
187
+ @own_turns.merged(id) if own?(origin.client_id)
198
188
  end
199
189
  nil
200
190
  end
201
191
 
202
192
  def own_prompt_failed
203
- @own_failed = true if @wait_at_eof
193
+ @own_turns.prompt_failed
204
194
  nil
205
195
  end
206
196
 
207
197
  # The input ended and nothing of ours is left to wait for.
208
198
  def end_of_input
209
- detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
210
- return :turn_failed if @own_failed
211
-
212
- @own_empty ? :empty_answer : :detached
199
+ detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @own_turns.input_ended?
200
+ @own_turns.outcome
213
201
  end
214
202
 
215
203
  # The prompt's `@path` images, stored where the worker reads them
@@ -14,6 +14,7 @@ require_relative "image_input"
14
14
  require_relative "line_reader"
15
15
  require_relative "question_prompt"
16
16
  require_relative "join_replay"
17
+ require_relative "own_turns"
17
18
  require_relative "exit_lines"
18
19
  require_relative "attached_loop/exit_commands"
19
20
  require_relative "attached_loop/questions"
@@ -68,8 +69,7 @@ module Samagotchi
68
69
  attr_reader :client_id
69
70
 
70
71
  # @return [String, nil] the model the worker's turns run on, as the
71
- # last command said
72
- # @return [String, nil] the worker's model
72
+ # last snapshot or model change said (the status row's)
73
73
  def model_name = @status[:model]
74
74
 
75
75
  # @return [QuestionPrompt, nil] the question waiting for an answer
@@ -152,21 +152,12 @@ module Samagotchi
152
152
  @join_replay = JoinReplay.new(screen: @screen, view: @view, link: @link, gaps: @gaps, renderer: @renderer)
153
153
  @last_idle_interrupt_at = nil
154
154
  @next_input_prefill = nil
155
- # Prompts this run sent, by enqueued_id: only those come back into
156
- # the input when their turn fails (a replayed event must not).
157
- @sent_ids = Set.new
158
155
  # This run's prompts queued for after the running turn (their ack
159
156
  # said queue), oldest first, until their turn starts or they are
160
157
  # removed: /unqueue takes back the newest.
161
158
  @my_queued = []
162
159
  @wait_at_eof = wait_at_eof
163
- # Sent and not ended yet (by enqueued_id), the ones merged into the
164
- # running turn, whether the input has ended, and whether one of this
165
- # run's prompts failed (wait_at_eof only).
166
- @open_ids = Set.new
167
- @merged_ids = Set.new
168
- @input_ended = false
169
- @own_failed = false
160
+ @own_turns = OwnTurns.new(active: wait_at_eof)
170
161
  # A continue offer is pending: the prompt asks for the answer.
171
162
  @continue_offer = nil
172
163
  # The /exit ask is open: the next line is its answer (#answer_exit_ask).
@@ -188,7 +179,8 @@ module Samagotchi
188
179
  # :failed when the first command (--model) didn't go through, or
189
180
  # (wait_at_eof) :turn_failed when one of this run's prompts failed,
190
181
  # :empty_answer when one ended with no answer, and :unanswered when
191
- # a question came after the input ended
182
+ # a question came after the input ended; :removed when this run took
183
+ # back its only queued prompt before it ran
192
184
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
193
185
  # Interrupt = Ctrl-C); defaults to Reline
194
186
  def run(input: nil)
@@ -225,7 +217,7 @@ module Samagotchi
225
217
 
226
218
  ended = take_early_lines
227
219
  return ended if ended
228
- return end_of_input if @input_ended && !own_turns_pending?
220
+ return end_of_input if @own_turns.input_ended? && !own_turns_pending?
229
221
  when :line
230
222
  # A pipe is read at once: its lines wait for the joining
231
223
  # snapshot, which may bring the question they answer.
@@ -256,7 +248,7 @@ module Samagotchi
256
248
  # has nothing left to answer it.
257
249
  return unanswered_question(@question_pending) if @question
258
250
 
259
- @input_ended = true
251
+ @own_turns.end_input
260
252
  return nil
261
253
  end
262
254
  end_of_input if submit(line) == :detach
@@ -303,7 +295,7 @@ module Samagotchi
303
295
  own_turn_ended(event)
304
296
  when :prompt_restored then restore_prompt(event)
305
297
  # The after_turn hooks are done (their notices came before it).
306
- when :answer_display then @display_pending = false
298
+ when :answer_display then @own_turns.displayed
307
299
  when :context_status, :used_memories_updated then @status.take_event(event)
308
300
  when :participants_changed then participants_changed(event)
309
301
  # Another client's anytime command: its line now, before the cards
@@ -333,7 +325,7 @@ module Samagotchi
333
325
  refresh_children if Array(event[:origins]).any? { |origin| report_origin?(origin_of(origin)) }
334
326
  when :question_requested
335
327
  # Nothing is left to answer it with.
336
- return unanswered_question(event[:pending_question]) if @input_ended
328
+ return unanswered_question(event[:pending_question]) if @own_turns.input_ended?
337
329
 
338
330
  ask(event[:pending_question])
339
331
  when :question_answered then question_answered(event)
@@ -515,7 +507,7 @@ module Samagotchi
515
507
  # goes back into the input, as the REPL restores it for a retry.
516
508
  def restore_prompt(event)
517
509
  origin = origin_of(event[:origin])
518
- return unless own?(origin.client_id) && @sent_ids.include?(origin.enqueued_id)
510
+ return unless own?(origin.client_id) && @own_turns.sent?(origin.enqueued_id)
519
511
  # Lines from a pipe: nobody to retry it (the REPL's rule).
520
512
  return if @wait_at_eof
521
513
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../tools/task_record"
4
+ require_relative "../worker_idle_exit"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -23,7 +24,7 @@ module Samagotchi
23
24
  "reminders" => "reminders are set", "starting" => "the worker is still starting",
24
25
  "background_tasks" => "background tasks it started are still running",
25
26
  "delegates" => "delegates of this session are still running"
26
- }.freeze
27
+ }.merge(WorkerIdleExit::LEAVING_WORDS.transform_keys(&:to_s)).freeze
27
28
  # How to end a background task that holds the worker up (an older
28
29
  # worker's 409 has no hint of its own).
29
30
  TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  id = @session_id
58
58
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
59
59
 
60
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
60
+ link = WorkerLink.resolve(session_dir, unlink: true)
61
61
  if link.is_a?(WorkerLink::Skew)
62
62
  @failure = link.message(id)
63
63
  return [:gone, "worker_newer"]
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  candidate = if found&.port == port
80
80
  found
81
81
  else
82
- link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
82
+ link = WorkerLink.resolve(session_dir, unlink: true)
83
83
  link.live? && link.port == port ? link.client(@session_id, host: @host) : nil
84
84
  end
85
85
  @client = candidate if candidate
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class TerminalUI
5
+ # This run's own prompts and commands. Sent prompts are always recorded so
6
+ # only this run's failed prompts can return to the input. With pipe input
7
+ # (+active+), this also tracks turns still open, prompts merged into a
8
+ # running turn, whether input ended, and whether a turn failed, was empty, was removed,
9
+ # or still has an answer display pending. #outcome is what #run returns
10
+ # once those turns finish.
11
+ class OwnTurns
12
+ def initialize(active:)
13
+ @active = active
14
+ @sent = Set.new
15
+ @open = Set.new
16
+ @merged = Set.new
17
+ @input_ended = false
18
+ @failed = false
19
+ @empty = false
20
+ @removed = false
21
+ @display_pending = false
22
+ end
23
+
24
+ def sent(enqueued_id)
25
+ @sent << enqueued_id
26
+ @open << enqueued_id if @active
27
+ end
28
+
29
+ def sent?(enqueued_id) = @sent.include?(enqueued_id)
30
+
31
+ def command_sent(command_id)
32
+ @open << command_id if @active
33
+ end
34
+
35
+ def command_ran(command_id) = @open.delete(command_id)
36
+
37
+ def merged(enqueued_id)
38
+ @merged << enqueued_id if @open.include?(enqueued_id)
39
+ end
40
+
41
+ def ended(enqueued_id, failed:, empty:, display_pending:)
42
+ return unless @active
43
+
44
+ ours = @open.include?(enqueued_id) || !@merged.empty?
45
+ @failed = true if ours && failed
46
+ @empty = true if ours && empty
47
+ @display_pending = true if ours && display_pending
48
+ @open.delete(enqueued_id)
49
+ @open.subtract(@merged)
50
+ @merged.clear
51
+ end
52
+
53
+ def displayed = (@display_pending = false)
54
+
55
+ def prompt_failed
56
+ @failed = true if @active
57
+ end
58
+
59
+ def dequeued(enqueued_id, by_us:)
60
+ return false unless @open.delete?(enqueued_id)
61
+
62
+ by_us ? (@removed = true) : (@failed = true)
63
+ true
64
+ end
65
+
66
+ def end_input = (@input_ended = true)
67
+
68
+ def input_ended? = @input_ended
69
+
70
+ def pending? = !@open.empty? || @display_pending
71
+
72
+ def outcome
73
+ return :turn_failed if @failed
74
+ return :empty_answer if @empty
75
+ return :removed if @removed
76
+
77
+ :detached
78
+ end
79
+ end
80
+ end
81
+ end
@@ -25,4 +25,12 @@ module Samagotchi
25
25
 
26
26
  def to_file = { "turn_id" => turn_id, "quote" => quote }
27
27
  end
28
+
29
+ VerifyAnchor::FEATURE = "verify"
30
+ VerifyAnchor::UNAVAILABLE = {
31
+ "error" => "verify_unavailable",
32
+ "detail" => "this session's worker predates Verify turns; restart this session to use Verify"
33
+ }.freeze
34
+
35
+ def VerifyAnchor.worker_unavailable?(features) = !features.nil? && !features.include?(VerifyAnchor::FEATURE)
28
36
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.127.0"
4
+ VERSION = "0.129.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -118,6 +118,9 @@ module Samagotchi
118
118
  # Seconds GET /api/models waits for the hosts' lists before answering
119
119
  # with what it has (the listing goes on and fills the registry's cache).
120
120
  MODELS_WAIT_TIMEOUT = 4.0
121
+ # The one refusal for an update operation (an install, a workers
122
+ # restart, a chi web restart) asked while another one runs: 409 update_running.
123
+ UPDATE_RUNNING = "a chi update operation is already running; try again when it ends"
121
124
  EVENTS_POLL = 1.0
122
125
 
123
126
  # A callable the event loops ask whether the server still runs;
@@ -702,7 +705,7 @@ module Samagotchi
702
705
  false
703
706
  end
704
707
  end
705
- return error_response(409, "update_running", "an update is already running") if already_running
708
+ return error_response(409, "update_running", UPDATE_RUNNING) if already_running
706
709
 
707
710
  @hub.publish_update(state: "running", rows: [], operation: "install", version: version)
708
711
  Thread.new { perform_update(version) }
@@ -774,7 +777,7 @@ module Samagotchi
774
777
  false
775
778
  end
776
779
  end
777
- return error_response(409, "update_running", "a chi update operation is already running") if already_running
780
+ return error_response(409, "update_running", UPDATE_RUNNING) if already_running
778
781
 
779
782
  @hub.publish_update(state: "running", rows: [], operation: "workers")
780
783
  Thread.new { perform_worker_restarts }
@@ -834,7 +837,7 @@ module Samagotchi
834
837
  @update_running = true # held for good once restarting: the exec ends this process
835
838
  false
836
839
  end
837
- return error_response(409, "update_running", "a chi update operation is running; restart chi web when it ends") if busy
840
+ return error_response(409, "update_running", UPDATE_RUNNING) if busy
838
841
 
839
842
  case @restart_web.call
840
843
  when true then return json_response(202, { state: "restarting" })
@@ -1721,8 +1724,7 @@ module Samagotchi
1721
1724
  outcome.is_a?(WorkerLink::Leaving) || outcome.is_a?(WorkerLink::Stale) ||
1722
1725
  outcome.is_a?(WorkerLink::Skew) ||
1723
1726
  (outcome.is_a?(WorkerLink::Starting) && outcome.stuck)
1724
- end,
1725
- host: DEFAULT_HOST)
1727
+ end)
1726
1728
  end
1727
1729
 
1728
1730
  def stream_link_error(id, link)
@@ -1741,7 +1743,7 @@ module Samagotchi
1741
1743
  def stream_open_error(id, outcome)
1742
1744
  return error_response(503, "not_live", WorkerLink.unanswered(id)) if outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
1743
1745
  return error_response(503, "not_live", WorkerLink.not_live(id)) if outcome.reason == :unknown_session
1744
- return error_response(502, "bridge_secret", BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
1746
+ return error_response(502, BridgeSecret::ERROR, BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
1745
1747
 
1746
1748
  error_response(502, "bridge_error", outcome.detail || "the session's worker answered #{outcome.status}")
1747
1749
  end
@@ -1752,7 +1754,7 @@ module Samagotchi
1752
1754
  # @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
1753
1755
  def worker_link(session_id)
1754
1756
  dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
1755
- WorkerLink.resolve(dir, unlink: true, host: DEFAULT_HOST)
1757
+ WorkerLink.resolve(dir, unlink: true)
1756
1758
  rescue StandardError
1757
1759
  WorkerLink::None.new
1758
1760
  end
@@ -51,7 +51,8 @@ module Samagotchi
51
51
  # current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
52
52
  def handle_review(req, id)
53
53
  session = @session_class.load(id, state_dir: default_state_dir)
54
- mode = ParticipantMode.live(session, state_dir: default_state_dir).to_s
54
+ features = ParticipantMode.live_features(session, default_state_dir)
55
+ mode = ParticipantMode.of(session, features: features).to_s
55
56
  viewer = participant_viewer(req, session)
56
57
  live = bridge_get_json(id, "snapshot")
57
58
  turn_snapshot = live && live["snapshot"]
@@ -67,7 +68,8 @@ module Samagotchi
67
68
  status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
68
69
  json_response(200, {
69
70
  viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
70
- session: { id: session.id, title: participant_title(session), status: status },
71
+ session: { id: session.id, title: participant_title(session), status: status,
72
+ verify_unavailable: VerifyAnchor.worker_unavailable?(features) },
71
73
  subjects: review_subjects(session, viewer),
72
74
  participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
73
75
  messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
@@ -32,6 +32,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
32
32
  // until its step closes)
33
33
  // @param onQuote (text) runs with the quote (and a preset's note) to append
34
34
  // to the composer; the selection is cleared and the bar hidden first
35
+ // @param disabledPreset (preset) → a reason to disable it, else null
35
36
  // @param actions () => the open session's annotation actions
36
37
  // (annotate_actions.js annotationActions), read on every selection
37
38
  // @param running () => whether a turn runs (a send pill may wait for it)
@@ -56,7 +57,8 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
56
57
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
57
58
  // next frame (call it on scroll)
58
59
  export function createAnnotateBar({
59
- presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, actions = () => [], running = () => false, onAction = () => {},
60
+ presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
61
+ actions = () => [], running = () => false, onAction = () => {},
60
62
  dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
61
63
  doc = globalThis.document, win = globalThis.window,
62
64
  }) {
@@ -80,6 +82,14 @@ export function createAnnotateBar({
80
82
  btn.title = text;
81
83
  bar.appendChild(btn);
82
84
  });
85
+ const presetButtons = [...bar.children].slice(1, presets.length + 1);
86
+ function markPresets() {
87
+ presetButtons.forEach((btn, i) => {
88
+ const reason = disabledPreset(presets[i]);
89
+ btn.disabled = !!reason;
90
+ btn.title = reason || presetParts(presets[i]).text;
91
+ });
92
+ }
83
93
  // The plugin pills: a separator, the inline ones, then ⋯ and the list
84
94
  // it opens. Redrawn only when the session's actions change.
85
95
  const group = doc.createElement("span");
@@ -175,6 +185,7 @@ export function createAnnotateBar({
175
185
  linkBtn.title = pendingLink?.title || "";
176
186
  const list = actions() || [];
177
187
  if (!sameActions(list, drawn)) drawActions(list);
188
+ markPresets();
178
189
  markActions();
179
190
  bar.hidden = false;
180
191
  if (touch?.matches) return placeDocked();
@@ -248,6 +259,7 @@ export function createAnnotateBar({
248
259
  return;
249
260
  }
250
261
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
262
+ if (btn.disabled || (preset && disabledPreset(preset))) return;
251
263
  const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
252
264
  const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
253
265
  win.getSelection()?.removeAllRanges();
@@ -256,7 +268,7 @@ export function createAnnotateBar({
256
268
  onQuote(text);
257
269
  });
258
270
 
259
- return { schedule, hide, el: bar };
271
+ return { schedule, hide, refresh: markPresets, el: bar };
260
272
  }
261
273
 
262
274
  // A preset's button label and note: a text is both.
@@ -59,12 +59,12 @@ import { createTaskStopper } from "./task_stop.js";
59
59
  import { createSystemOneWrongAction } from "./system_one_wrong.js";
60
60
  import { appendQuote } from "./annotations.js";
61
61
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
62
- import { nameVerdictCards } from "./verdict_card.js";
63
- import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
62
+ import { CHECK_NOT_ON_PAGE, nameVerdictCards, ownerCheckHash } from "./verdict_card.js";
63
+ import { freshURL, placeVerdictWithFresh, redrawFresh, settleRunning } from "./verdict_fresh.js";
64
64
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
65
65
  import { parsePresets } from "./annotate_presets.js";
66
66
  import { createAnnotateBar } from "./annotate_bar.js";
67
- import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
67
+ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
68
68
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
69
69
  import { copyText, installCopy } from "./copy.js";
70
70
  import { createQuestionCards } from "./question_cards.js";
@@ -235,6 +235,7 @@ const composeHintEl = $("#composeHint");
235
235
 
236
236
  let selected = null;
237
237
  let pendingVerifyAnchor = null;
238
+ let annotateBar = null;
238
239
  // The session the composer's text was last written in ("" the start page,
239
240
  // undefined unknown): the composer is one for every session, and continue →
240
241
  // keeps only a draft of its own (fillOpener).
@@ -589,7 +590,13 @@ function copyUpdateCommand(command) {
589
590
  function followSelectedSession(sess) {
590
591
  current.owner = sess.owner || null;
591
592
  current.workerVersion = sess.worker_version || null;
593
+ const verifyWas = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
592
594
  current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
595
+ current.bridgeUp = sess.bridge_up === true;
596
+ // The Verify gate flipped (a restart brought the feature): the cards' Verify fresh part follows.
597
+ if (verifyWas !== verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp)) {
598
+ redrawFresh(historyEl, verifyFreshState());
599
+ }
593
600
  const streaming = !!(streamControl && streamControl.live);
594
601
  if (!streaming) current.status = sess.status;
595
602
  updateInfoBar();
@@ -1344,18 +1351,27 @@ function placeVerdict(record) {
1344
1351
  placeVerdictWithFresh(historyEl, record, {
1345
1352
  who: badge ? badge.name : "You",
1346
1353
  author: record.by || null,
1354
+ onMissingCheck: (missing) => {
1355
+ const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
1356
+ if (hash) location.hash = hash;
1357
+ else showToast(CHECK_NOT_ON_PAGE);
1358
+ },
1347
1359
  }, verifyFreshState());
1348
1360
  }
1349
1361
 
1350
1362
  // The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
1351
1363
  // always theirs to start; its running check links to the child's session.
1364
+ const verifyFreshErrors = {};
1352
1365
  function verifyFreshState() {
1353
1366
  const id = selected;
1367
+ const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
1354
1368
  if (!Array.isArray(current.verifyFresh)) current.verifyFresh = [];
1355
1369
  return {
1356
1370
  verdicts: current.verdicts || [],
1357
1371
  running: current.verifyFresh,
1358
- canStart: !!id,
1372
+ errors: verifyFreshErrors,
1373
+ canStart: !!id && !unavailable,
1374
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
1359
1375
  start: async (of) => {
1360
1376
  try {
1361
1377
  const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
@@ -1421,6 +1437,7 @@ async function restartWorker() {
1421
1437
  // The session bar (info_bar_model.js sessionBar): its buttons, and the
1422
1438
  // info line rendered by info_bar.js.
1423
1439
  function updateInfoBar() {
1440
+ annotateBar?.refresh();
1424
1441
  const { buttons, bar } = sessionBar({
1425
1442
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1426
1443
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
@@ -1574,6 +1591,7 @@ function workerGone(id, { opened = true } = {}) {
1574
1591
  if (!stopped) setLiveStatus("idle");
1575
1592
  }
1576
1593
  if (current.owner === "worker") current.owner = null;
1594
+ current.bridgeUp = false;
1577
1595
  // Its card says "live" (the list's owner, or setLiveStatus's) until the
1578
1596
  // hub's event says otherwise: the worker is gone now, as far as this
1579
1597
  // tab can tell.
@@ -1956,8 +1974,10 @@ function fillComposer(text) {
1956
1974
  promptEl.scrollTop = promptEl.scrollHeight;
1957
1975
  }
1958
1976
 
1959
- const annotateBar = createAnnotateBar({
1977
+ annotateBar = createAnnotateBar({
1960
1978
  presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1979
+ disabledPreset: (preset) => preset === VERIFY_PRESET
1980
+ && verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
1961
1981
  canAnnotate: () => !!selected && current.owner !== "tui",
1962
1982
  roots: turnRoots,
1963
1983
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),