samagotchi 0.146.0 → 0.148.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +14 -5
  5. data/docs/bundles.md +26 -16
  6. data/docs/cli.md +6 -2
  7. data/docs/configuration.md +19 -4
  8. data/docs/guardrails.md +25 -4
  9. data/docs/sessions.md +2 -2
  10. data/docs/system-one.md +1 -1
  11. data/lib/samagotchi/answer_tail.rb +1 -1
  12. data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
  13. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -5
  14. data/lib/samagotchi/bundles/coordinator/plugin.rb +2 -1
  15. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +35 -13
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +5 -0
  18. data/lib/samagotchi/guardrail_wiring.rb +11 -6
  19. data/lib/samagotchi/guardrails/approvals.rb +3 -1
  20. data/lib/samagotchi/guardrails/rules.rb +6 -1
  21. data/lib/samagotchi/guardrails/verdict.rb +21 -1
  22. data/lib/samagotchi/guardrails_command.rb +47 -3
  23. data/lib/samagotchi/run_options.rb +1 -0
  24. data/lib/samagotchi/send_command.rb +2 -1
  25. data/lib/samagotchi/session_chain.rb +91 -8
  26. data/lib/samagotchi/session_commands.rb +1 -1
  27. data/lib/samagotchi/session_continue.rb +3 -2
  28. data/lib/samagotchi/system_one/record.rb +17 -3
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +1 -1
  31. data/lib/samagotchi/web/auto_update.rb +60 -5
  32. data/lib/samagotchi/web/public/app.js +5 -16
  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/session_access_routes.rb +1 -1
  36. data/lib/samagotchi/web/session_hub.rb +17 -4
  37. metadata +2 -1
@@ -323,7 +323,7 @@ module Samagotchi
323
323
  # one that passes every gate is allowed once without reaching them
324
324
  # (docs/guardrails.md, "System 1").
325
325
  judgment = begin
326
- system_one_judge(payload, targets: verdict.targets)
326
+ system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
327
327
  rescue LLM::RequestCancelled
328
328
  return turn_stopped(verdict)
329
329
  end
@@ -366,8 +366,9 @@ module Samagotchi
366
366
  # auto-allowed: its asks go to the owner. No client (mode off, no
367
367
  # host) means no line at all. A judgment that fails is logged with p nil,
368
368
  # so the failures are in the log too.
369
+ # With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
369
370
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
370
- def system_one_judge(payload, targets: nil)
371
+ def system_one_judge(payload, targets: nil, ask_user: false)
371
372
  return nil if Steer.participant_author?(turn_author)
372
373
 
373
374
  client = @system_one.call
@@ -397,13 +398,13 @@ module Samagotchi
397
398
  end
398
399
  latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
399
400
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
400
- auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
+ auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
402
  decision = system_one_decision(client, auto)
402
403
 
403
404
  id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
404
405
  rule: approval.rule, source: approval.source, tool: approval.tool,
405
406
  **SystemOne::ApprovalQuestion.call_fields(approval),
406
- repo_name: approval.repo_name,
407
+ repo_name: approval.repo_name, repo: targets&.repo,
407
408
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
408
409
  latency_ms: latency_ms, decision: decision, origin: "live")
409
410
  # A judgment the log cannot hold is no judgment: the user is asked.
@@ -443,7 +444,8 @@ module Samagotchi
443
444
 
444
445
  # Every gate an auto-allow must pass, in order: the mode, the rule the
445
446
  # user opted in, the never-list and chi's own files, the probability, and
446
- # the rule's record. Any one that fails leaves the ask to the user. The
447
+ # the rule's record (this repo's for a rule that goes by repo; with no repo
448
+ # that is none). Any one that fails leaves the ask to the user. The
447
449
  # entries are the judgment log read once for this ask.
448
450
  # @param approval [Guardrails::ApprovalFacts] the ask's facts
449
451
  # @param entries [Array<Hash>] SystemOne::Log#entries
@@ -460,7 +462,10 @@ module Samagotchi
460
462
  return false if p.nil? || p.to_f < threshold
461
463
 
462
464
  min_record = Config.get("guardrails.system_one.min_record").to_i
463
- SystemOne::Record.new(entries, rule: rule).qualifies?(threshold: threshold, min_record: min_record)
465
+ per_repo = SystemOne::Record.per_repo?(rule)
466
+ return false if per_repo && targets&.repo.nil?
467
+
468
+ SystemOne::Record.new(entries, rule: rule, repo: (targets.repo if per_repo)).qualifies?(threshold: threshold, min_record: min_record)
464
469
  rescue StandardError => e
465
470
  Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
466
471
  false
@@ -80,7 +80,9 @@ module Samagotchi
80
80
  case e["scope"]
81
81
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
82
82
  when "repo" then here.call(e) && e["key"] == key
83
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
83
+ when "rule"
84
+ # One rule's grant can't answer for a second rule's ask on the call.
85
+ !verdict.second_rule_ask? && verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
84
86
  end
85
87
  end
86
88
  end
@@ -310,10 +310,13 @@ module Samagotchi
310
310
  @disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
311
311
  end
312
312
 
313
- # A core check: every matching rule votes (strictest wins).
313
+ # A core check: every matching rule votes (strictest wins). A second
314
+ # rule id that asks takes rule grants out (Verdict#second_rule_asks!);
315
+ # asks from before the rules (the child boundary's) stay as they are.
314
316
  def check(verdict)
315
317
  return verdict unless @enabled
316
318
 
319
+ asking = nil
317
320
  @rules.each do |rule|
318
321
  next if disabled?(rule)
319
322
  next unless rule.for_mode?(@mode)
@@ -323,6 +326,8 @@ module Samagotchi
323
326
  verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
324
327
  else
325
328
  verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
329
+ verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
330
+ asking ||= rule.id
326
331
  end
327
332
  end
328
333
  verdict
@@ -8,7 +8,8 @@ module Samagotchi
8
8
  # first one stays, except that a protected rule's ask (PROTECTED_RULES)
9
9
  # takes over an unprotected ask: its rule id is what a parent's answer
10
10
  # and a stored approval are checked against, and the offered scopes
11
- # narrow to both asks' common ones.
11
+ # narrow to both asks' common ones. A second rule's ask takes rule grants
12
+ # out (#second_rule_asks!): the one id it keeps can't answer for both.
12
13
  class Verdict
13
14
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
15
  # Rules that ask about chi's own config and hooks: the core
@@ -47,6 +48,9 @@ module Samagotchi
47
48
 
48
49
  def fresh_ask? = @fresh_ask == true
49
50
 
51
+ # Whether a second distinct rule asks about this call (#second_rule_asks!).
52
+ def second_rule_ask? = @second_rule_ask == true
53
+
50
54
  def initialize(call:)
51
55
  @call = call
52
56
  @decision = :allow
@@ -84,6 +88,22 @@ module Samagotchi
84
88
  self
85
89
  end
86
90
 
91
+ # A second distinct rule asks about this call (Rules#check). The ask
92
+ # names one rule, and a rule grant or System 1's record is about that
93
+ # rule alone, so neither answers for the other's: no rule-scope entry
94
+ # matches it (Approvals.find_in), System 1 doesn't auto-allow it, and it
95
+ # offers what both rules allow, minus the rule. An exact-call entry
96
+ # (session, repo) still answers: it is keyed on the whole command.
97
+ # @param scopes [Array<String>, nil] the second rule's scopes (default all)
98
+ def second_rule_asks!(scopes = nil)
99
+ @second_rule_ask = true
100
+ allowed = Array(scopes).map(&:to_s) & SCOPES
101
+ allowed = SCOPES if allowed.empty?
102
+ left = (@scopes & allowed) - %w[rule]
103
+ @scopes = left.empty? ? %w[once] : left
104
+ self
105
+ end
106
+
87
107
  def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
88
108
 
89
109
  # A before_tool_call hook set the old event[:blocked] flag. Its model
@@ -8,6 +8,7 @@ require_relative "cli/flags"
8
8
  require_relative "config"
9
9
  require_relative "bridge/card_store"
10
10
  require_relative "guardrails/approval"
11
+ require_relative "guardrail_wiring"
11
12
  require_relative "guardrails/approvals"
12
13
  require_relative "guardrails/parent_policy"
13
14
  require_relative "host_registry"
@@ -20,7 +21,10 @@ module Samagotchi
20
21
  # seed: System 1 judges every approval this machine's sessions already
21
22
  # answered, and the judgment and the user's answer go to the log as they
22
23
  # would live. A rule's record then starts with the asks the user really
23
- # decided on, instead of waiting for a few days of live ones.
24
+ # decided on, instead of waiting for a few days of live ones. `rule` says
25
+ # whether one rule votes `ask` on this machine now, read from the config and
26
+ # the installed bundles as a worker reads them (GuardrailWiring#rules), by
27
+ # its id: the text of a command never comes near it.
24
28
  class GuardrailsCommand
25
29
  include CLI::Command
26
30
 
@@ -41,16 +45,24 @@ module Samagotchi
41
45
  --dry-run judges and prints the table without writing a line. Needs
42
46
  guardrails.system_one.mode audit or on and a system_one.host
43
47
  (exit 2 without one).
48
+ rule <id>
49
+ Print exactly "asks" when the rule is loaded, not disabled and votes
50
+ ask in this guardrails.mode for every model; another line says why not
51
+ when it isn't (exit 0 either way). Reads config.yml and the installed
52
+ bundles, as a worker does: no session, no model, nothing written.
44
53
  TEXT
45
54
 
46
- SUBCOMMANDS = %w[replay].freeze
55
+ SUBCOMMANDS = %w[replay rule].freeze
47
56
  FLAGS = {
48
57
  "replay" => CLI::Flags.new(help: HELP_WORDS, args: false) do |f|
49
58
  f.switch "--dry-run"
50
59
  f.value "--threshold"
51
60
  f.value "--model"
52
- end
61
+ end,
62
+ "rule" => CLI::Flags.new(help: HELP_WORDS)
53
63
  }.freeze
64
+ # What `rule` prints when a rule asks. An agent compares the line to it.
65
+ ASKS = "asks"
54
66
  # The labels that mean the user let the call through.
55
67
  ALLOW_LABELS = %w[allow_once allow_wide].freeze
56
68
  # replay's table, in column order: the rule, how many asks it had, how
@@ -135,6 +147,8 @@ module Samagotchi
135
147
  parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
136
148
  return parsed if parsed.is_a?(Integer)
137
149
 
150
+ return rule(parsed.args) if @sub == "rule"
151
+
138
152
  replay(parsed.options)
139
153
  end
140
154
 
@@ -142,6 +156,36 @@ module Samagotchi
142
156
 
143
157
  def command_name = @sub ? "chi guardrails #{@sub}" : "chi guardrails"
144
158
 
159
+ def rule(args)
160
+ return usage_error("give one rule id") unless args.size == 1
161
+
162
+ @stdout.puts(rule_status(args.first))
163
+ CLI::Exit::OK
164
+ end
165
+
166
+ # The rule set as a worker would load it now, the rule's id looked up in
167
+ # it. Every other answer than ASKS names what stands in the way.
168
+ def rule_status(id)
169
+ wiring = GuardrailWiring.new(scratch: false, hooks: -> {}, tools: -> {}, session: -> {}, model_key: -> {},
170
+ cancelled: -> { false }, ask: ->(_fields) {})
171
+ rules = wiring.rules
172
+ failed = wiring.failures.required
173
+ return "rules failed to load: #{failed.map(&:what).join(", ")}" unless failed.empty?
174
+ return "guardrails are off (guardrails.enabled: false)" unless rules.enabled?
175
+
176
+ named = rules.rules.select { |r| r.id == id }
177
+ return "no rule #{id} is loaded" if named.empty?
178
+
179
+ enabled = named.reject { |r| rules.disabled?(r) }
180
+ return "rule #{id} is disabled (guardrails.disable)" if enabled.empty?
181
+
182
+ voting = enabled.select { |r| r.for_mode?(rules.mode) }
183
+ return "rule #{id} doesn't vote in guardrails.mode #{rules.mode}" if voting.empty?
184
+ return "rule #{id} denies" if voting.any? { |r| r.verdict == :deny }
185
+
186
+ voting.any? { |r| r.models.nil? } ? ASKS : "rule #{id} asks for some models only"
187
+ end
188
+
145
189
  def replay(options)
146
190
  threshold = threshold(options[:threshold])
147
191
  return usage_error(threshold) if threshold.is_a?(String)
@@ -207,6 +207,7 @@ module Samagotchi
207
207
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
208
208
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
209
209
  chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
210
+ chi guardrails rule <id> print "asks" if that rule votes ask on this machine now
210
211
  chi judgments ls | report NAME System 1's saved asks on this machine
211
212
  chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
212
213
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
@@ -76,7 +76,8 @@ module Samagotchi
76
76
  in its folder, on its model (or --model's; one whose
77
77
  host is gone falls back to the default, with a
78
78
  warning), LLM context, thinking level, toolkit and
79
- bundle settings, with a note carrying its recap; it
79
+ bundle settings, with a note carrying its recap and
80
+ last answer; it
80
81
  is archived.
81
82
  last:ID names the chain's latest link.
82
83
  Without -m the new session waits idle. Refused for a
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "answer_tail"
5
+ require_relative "config"
6
+ require_relative "log"
7
+ require_relative "output_formatter"
4
8
  require_relative "session"
9
+ require_relative "session_inbox"
10
+ require_relative "steer"
5
11
  require_relative "recap_store"
6
12
 
7
13
  module Samagotchi
@@ -16,9 +22,16 @@ module Samagotchi
16
22
  LAST_PREFIX = "last:"
17
23
  # Who the carry note is from (ContextNote's label: "note from session chain").
18
24
  NOTE_SOURCE = "session chain"
19
- # The most of the previous link's recap the carry note takes; a recap is
20
- # a few sentences, this only keeps a runaway one under the note's 16 KiB.
25
+ # The most of the previous link's recap a note takes; a recap is a few
26
+ # sentences. Characters, not bytes: #note_text holds the carry note to
27
+ # SessionInbox::NOTE_MAX_BYTES itself.
21
28
  RECAP_CHARS = 8000
29
+ # The most of the previous link's last answer the carry note takes. A
30
+ # wrap-up closes on what comes next, so a longer one keeps its end.
31
+ LAST_ANSWER_CHARS = 4000
32
+ LAST_ANSWER_HEAD = "\n\nIts last answer:\n"
33
+ # The "…" that marks a cut, in bytes.
34
+ CUT_MARK_BYTES = 3
22
35
 
23
36
  module_function
24
37
 
@@ -63,14 +76,79 @@ module Samagotchi
63
76
  end
64
77
 
65
78
  # The context note a new link starts with: which session it continues,
66
- # from when, and that session's recap with its time, when it has one.
67
- # Never more than the recap: no transcript.
68
- # @param previous [Session]
79
+ # from when, that session's recap with its time, when it has one, and
80
+ # its last answer (#last_answer; session.carry_last_answer). No other transcript. Never over
81
+ # SessionInbox::NOTE_MAX_BYTES, whatever the session holds: the inbox
82
+ # rejects a longer note, and a continue writes it after the archive. The
83
+ # answer gets what the rest leaves.
84
+ # @param previous [Session] the answer is read fresh from its file
69
85
  # @param recap [Hash, nil] RecapStore.read's
70
86
  # @return [String]
71
- def note_text(previous, recap:)
87
+ def note_text(previous, recap:, state_dir:)
72
88
  head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
73
- "#{head}#{recap_text(recap)}"
89
+ text = keep_start("#{head}#{recap_text(recap)}", SessionInbox::NOTE_MAX_BYTES)
90
+ room = SessionInbox::NOTE_MAX_BYTES - text.bytesize - LAST_ANSWER_HEAD.bytesize
91
+ answer = carried_answer(previous, room: room, state_dir: state_dir)
92
+ answer ? "#{text}#{LAST_ANSWER_HEAD}#{answer}" : text
93
+ end
94
+
95
+ # The last answer as the carry note takes it, in at most +room+ bytes;
96
+ # nil with session.carry_last_answer off, when there is none, when a
97
+ # review link has it (a continue copies review: but not the grants, so
98
+ # the next link's participants could differ) or when the session can't
99
+ # be read: this runs inside a continue's rollback, and a note without it
100
+ # is still a good note.
101
+ # @return [String, nil]
102
+ def carried_answer(previous, room:, state_dir:)
103
+ return nil if previous.review? || !Config.get("session.carry_last_answer")
104
+
105
+ text = last_answer(Session.load(previous.id, state_dir: state_dir).messages)
106
+ text && keep_end(text, room)
107
+ rescue StandardError => e
108
+ Log.warn(:worker, "continue_last_answer_failed", sid: previous.id, error: e.class.name, msg: e.message)
109
+ nil
110
+ end
111
+
112
+ # The session's last word: the text of AnswerTail's newest bubble, with
113
+ # the wire-format markup stripped, unless that bubble is a step of a
114
+ # turn (it asked for tools) or old text a later turn never replaced
115
+ # (one that failed or was cut before it answered). A cut reply keeps
116
+ # its "[interrupted]": that is what happened.
117
+ # @param messages [Array<Hash>]
118
+ # @return [String, nil]
119
+ def last_answer(messages)
120
+ found = AnswerTail.find(messages)
121
+ return nil unless found && found[:content].is_a?(String) && Array(found[:tool_calls]).empty?
122
+
123
+ at = messages.rindex { |m| m.equal?(found) }
124
+ return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
125
+
126
+ OutputFormatter.strip_markup(found[:content].scrub)
127
+ end
128
+
129
+ # A turn's opening, not a `!shell` line: a command the user ran after
130
+ # the answer leaves it the last word.
131
+ def later_turn?(message)
132
+ Steer.turn_prompt?(message) && !shell_line?(message)
133
+ end
134
+
135
+ # At most +max+ bytes of +text+'s start, cut on a character.
136
+ def keep_start(text, max)
137
+ return text if text.bytesize <= max
138
+
139
+ "#{text.byteslice(0, max - CUT_MARK_BYTES).scrub("")}…"
140
+ end
141
+
142
+ # +text+'s end: at most LAST_ANSWER_CHARS characters and +max+ bytes,
143
+ # with the "…" of a cut; nil when no character fits.
144
+ def keep_end(text, max)
145
+ return nil if max <= CUT_MARK_BYTES
146
+
147
+ text = "…#{text[(1 - LAST_ANSWER_CHARS)..]}" if text.length > LAST_ANSWER_CHARS
148
+ return text if text.bytesize <= max
149
+
150
+ kept = text.byteslice(-(max - CUT_MARK_BYTES), max - CUT_MARK_BYTES).scrub("")
151
+ kept.empty? ? nil : "…#{kept}"
74
152
  end
75
153
 
76
154
  # +recap+ as a note's tail: " Its recap, as of <time>:\n<text>" (cut
@@ -103,13 +181,18 @@ module Samagotchi
103
181
 
104
182
  text = msg[:content]
105
183
  next unless text.is_a?(String) && !text.strip.empty?
106
- next if msg[:turn_id].nil? && text.start_with?("!(")
184
+ next if shell_line?(msg)
107
185
 
108
186
  return text.strip
109
187
  end
110
188
  nil
111
189
  end
112
190
 
191
+ # A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
192
+ def shell_line?(message)
193
+ message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
194
+ end
195
+
113
196
  def clock(iso)
114
197
  iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
115
198
  rescue ArgumentError
@@ -534,7 +534,7 @@ module Samagotchi
534
534
  rules.each do |rule|
535
535
  summary = SystemOne::Record.new(entries, rule: rule).summary
536
536
  counts = summary.reject { |key, _| key == :rule }.map { |key, value| "#{key}=#{value}" }.join(", ")
537
- lines << " #{rule}: #{counts}"
537
+ lines << " #{rule}#{" (per repo)" if SystemOne::Record.per_repo?(rule)}: #{counts}"
538
538
  end
539
539
  end
540
540
  end
@@ -69,7 +69,8 @@ module Samagotchi
69
69
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
70
70
  # it was typed as, so an alias holds) and with its own setup (SessionSetup),
71
71
  # starting with a context note from chi: the link and the previous
72
- # link's recap (SessionChain.note_text). +model+, when given, is the
72
+ # link's recap and last answer (SessionChain.note_text), read after the
73
+ # recap wait, so a turn that finished in it counts. +model+, when given, is the
73
74
  # new link's model instead; an inherited one whose host can't run it any
74
75
  # more (gone, disabled, an alias naming another host) falls back to the
75
76
  # default with a warning (#model_for). The previous link is archived
@@ -212,7 +213,7 @@ module Samagotchi
212
213
  link = SessionManager.spawn_session(
213
214
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
214
215
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
215
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
216
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
216
217
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
217
218
  )
218
219
  link.moved_children = move
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config"
4
+ require_relative "../model_match"
5
+
3
6
  module Samagotchi
4
7
  module SystemOne
5
8
  # One rule's record: what System 1 has judged for it and what the user
@@ -7,7 +10,9 @@ module Samagotchi
7
10
  # joined with their last label), filtered to one rule. The gate before
8
11
  # System 1 answers for the user: enough clean allows at the threshold,
9
12
  # and no false allow among the rule's last 20 labelled asks
10
- # (docs/guardrails.md, "System 1").
13
+ # (docs/guardrails.md, "System 1"). A rule in
14
+ # guardrails.system_one.repo_rules goes by repository: what the user allowed
15
+ # in one says nothing about another, so its record is built per repo.
11
16
  #
12
17
  # An ask with no label, no probability, or a `none` label counts for
13
18
  # neither side: only a judgment the user actually answered teaches
@@ -21,13 +26,22 @@ module Samagotchi
21
26
  # How many of the rule's labelled asks the false-allow check looks at.
22
27
  WINDOW = 20
23
28
 
29
+ # Whether +rule+'s record goes by repository
30
+ # (guardrails.system_one.repo_rules, read like .rules).
31
+ def self.per_repo?(rule)
32
+ ModelMatch.parse(Config.get("guardrails.system_one.repo_rules")).include?(rule.to_s)
33
+ end
34
+
24
35
  attr_reader :rule, :entries
25
36
 
26
37
  # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
27
38
  # @param rule [String] the rule the record is for
28
- def initialize(entries, rule:)
39
+ # @param repo [String, nil] only this repository's asks (a judgment's
40
+ # repo: Guardrails::Targets#repo, its common git dir); nil: all of the
41
+ # rule's. A line with no repo is in no repository's record.
42
+ def initialize(entries, rule:, repo: nil)
29
43
  @rule = rule.to_s
30
- @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
44
+ @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
31
45
  end
32
46
 
33
47
  # @param threshold [Float] the probability an auto-allow needs
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.146.0"
4
+ VERSION = "0.148.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 } })
@@ -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";
@@ -585,7 +585,7 @@ function noticeVersions(data) {
585
585
  const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
586
586
  installed: versions.installed, available: versions.available,
587
587
  updateBlocked: versions.updateBlocked, auto: versions.auto, restart: versions.restart,
588
- autoInstall: versions.autoInstall, shown: versionNoticesShown });
588
+ autoOffers: versions.autoOffers, shown: versionNoticesShown });
589
589
  store.touch("infoBar"); // the worker badge compares with the newest installed
590
590
  if (!notice) return;
591
591
  versionNoticesShown.add(notice.key);
@@ -614,20 +614,9 @@ function noticeVersions(data) {
614
614
  function updateFrame(data, { snapshot = false } = {}) {
615
615
  const wasOpen = updateSheet.isOpen();
616
616
  if (!updateSheet.receive(data)) return;
617
- // Every install frame, not only the done one: the hub's `chi` frame with
618
- // the new installed version comes before it, and an automatic run's
619
- // running frame is what keeps that from offering the Restart toast.
620
- if (data.operation === "install") {
621
- noticeVersions({ ...(data.state === "done" && data.version ? { installed: data.version } : {}), chi_update: data });
622
- }
623
- const label = `${data.auto ? "Automatic " : ""}${data.operation === "install" ? "chi update" : "worker restart"}`
624
- .replace(/^./, (c) => c.toUpperCase());
625
- if (!wasOpen && data.state === "running") {
626
- showToast(`${label} in progress`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
627
- } else if (!snapshot && !wasOpen && ["done", "failed"].includes(data.state)
628
- && !(data.operation === "install" && data.state === "done")) {
629
- showToast(`${label} ${data.state}`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
630
- }
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 });
631
620
  }
632
621
 
633
622
  function copyUpdateCommand(command) {