samagotchi 0.86.0 → 0.88.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 +32 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +5 -3
  5. data/docs/cli.md +18 -5
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +53 -3
  8. data/docs/hooks.md +1 -1
  9. data/docs/plugins.md +1 -1
  10. data/docs/sessions.md +19 -7
  11. data/docs/sub-agent.md +15 -3
  12. data/lib/samagotchi/answer_command.rb +6 -6
  13. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  14. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_reports.rb +100 -7
  17. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  18. data/lib/samagotchi/guardrails/parent_policy.rb +94 -5
  19. data/lib/samagotchi/parent_answers_command.rb +80 -0
  20. data/lib/samagotchi/parent_report.rb +53 -34
  21. data/lib/samagotchi/question_desk.rb +3 -2
  22. data/lib/samagotchi/relay.rb +34 -2
  23. data/lib/samagotchi/relay_desk.rb +13 -0
  24. data/lib/samagotchi/send_command.rb +75 -10
  25. data/lib/samagotchi/session.rb +26 -2
  26. data/lib/samagotchi/session_commands.rb +35 -9
  27. data/lib/samagotchi/session_setup.rb +10 -8
  28. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -5
  30. data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
  31. data/lib/samagotchi/terminal_ui.rb +12 -6
  32. data/lib/samagotchi/tools/delegate.rb +4 -1
  33. data/lib/samagotchi/tools/delegate_relay.rb +69 -15
  34. data/lib/samagotchi/tools/delegate_wait.rb +14 -8
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/notify.js +6 -2
  37. data/lib/samagotchi/web/public/question_card.js +2 -0
  38. data/lib/samagotchi/web/public/question_cards.js +25 -18
  39. data/lib/samagotchi/worker.rb +60 -10
  40. metadata +2 -1
@@ -130,7 +130,13 @@ module Samagotchi
130
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
131
131
  # and the cursor it was read from (a commit after the model moved it
132
132
  # itself, with delegate_result, leaves it alone).
133
- Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
+ # +relay+: the child's question when it goes to the parent's user
134
+ # (Tools::DelegateRelay.relay?): #resolve relays it on the turn thread
135
+ # and gives the report its outcome line; +text+ is the question's
136
+ # report until then (what a parent with no relay gets).
137
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id, :relay) do
138
+ def initialize(relay: nil, **) = super
139
+
134
140
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
135
141
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
136
142
  end
@@ -146,14 +152,19 @@ module Samagotchi
146
152
  Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
147
153
  # child id → the Report this turn took for it (rings merged).
148
154
  @taken = {}
155
+ # child id → the question id whose relay card a Stop closed (#hold_back).
156
+ @held = {}
149
157
  end
150
158
 
159
+ # The user's next input: questions held back after a Stop are offered again.
160
+ def release_holds = @held.clear
161
+
151
162
  # Whether a ring this turn hasn't taken waits (the idle loop's check).
152
163
  def waiting?
153
164
  return false if ChildRing.mode == "off"
154
165
 
155
166
  taken = taken_rings
156
- ring_files.any? { |file| !taken.include?(file) }
167
+ ring_files.any? { |file| !taken.include?(file) && !held?(file) }
157
168
  end
158
169
 
159
170
  # The reports for rings this turn hasn't taken yet, recorded as taken.
@@ -164,7 +175,7 @@ module Samagotchi
164
175
  return [] if ChildRing.mode == "off"
165
176
 
166
177
  taken = taken_rings
167
- fresh = ring_files.reject { |file| taken.include?(file) }
178
+ fresh = ring_files.reject { |file| taken.include?(file) || held?(file) }
168
179
  by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
169
180
  by_child.filter_map do |child_id, rings|
170
181
  report = child_id && report_for(child_id, rings)
@@ -207,8 +218,79 @@ module Samagotchi
207
218
  @taken = {}
208
219
  end
209
220
 
221
+ # A report whose question goes to the parent's user, settled: relayed
222
+ # through +relay+ (DelegateRelay.call: blocks until the card is
223
+ # answered, closed or the turn stopped) and given the outcome line as
224
+ # its text. Runs on the turn thread, before the boundary merges it.
225
+ # The child's question answered since the ring was taken opens no card;
226
+ # nor does one already answered on a relay (RelayDesk#settled?).
227
+ # @param relay [#open_question, #relay_desk, nil] Engine#relay_peer;
228
+ # nil: the report's question text, as without P4's relay
229
+ # @return [Report, nil] nil when the parent's turn was stopped with the
230
+ # card open: the report is released (its ring and the cursor stay), so
231
+ # the next turn offers it again
232
+ def resolve(report, relay:)
233
+ return report unless report.relay
234
+ return report.with(relay: nil) unless relay
235
+
236
+ question = report.relay
237
+ what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
238
+ line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
239
+ Tools::DelegateRelay.line(what, "answered on the child", hook: true)
240
+ elsif relay.relay_desk.settled?(report.child_id, question.id)
241
+ Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
242
+ else
243
+ outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
244
+ return hold_back(report) if outcome.stopped
245
+
246
+ outcome.line
247
+ end
248
+ resolved = report.with(relay: nil, text: relayed_text(report.child_id, line))
249
+ @taken[report.child_id] = resolved if @taken[report.child_id]&.claim_id == report.claim_id
250
+ resolved
251
+ rescue ArgumentError => e
252
+ # The child is gone (or a bug): its question's report, as without a relay.
253
+ Log.info(:worker, "delegate_relay_unresolved", child: report.child_id[0, 8], error: e.message)
254
+ report.with(relay: nil)
255
+ end
256
+
257
+ # A relay report the parent's turn stopped on, or one behind it in the
258
+ # same take: released (its ring and the cursor stay) and held back, so
259
+ # no wake turn offers that question again until the user's next input
260
+ # (#release_holds); other children's rings go on.
261
+ # @return [nil]
262
+ def hold_back(report)
263
+ @held[report.child_id] = report.relay.id.to_s
264
+ release_report(report)
265
+ end
266
+
210
267
  private
211
268
 
269
+ # A ring of a child whose held-back question still waits.
270
+ def held?(file)
271
+ child_id = SessionInbox.read_ring(file)&.dig(:child_id)
272
+ question_id = @held[child_id] or return false
273
+ Session.load(child_id, state_dir: @state_dir).pending_question&.id.to_s == question_id
274
+ rescue ArgumentError
275
+ false
276
+ end
277
+
278
+ # A relayed question's report: the outcome line, then the next reply's
279
+ # promise (as a report's question text ends).
280
+ def relayed_text(child_id, line)
281
+ Tools::DelegateWait.result(child_id, "question", "#{line}\n#{Tools::DelegateWait::REPORT_NEXT_REPLY}")
282
+ end
283
+
284
+ # One taken report back: its cursor claim goes, its rings stay untaken.
285
+ # @return [nil]
286
+ def release_report(report)
287
+ @taken.delete(report.child_id)
288
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
289
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
290
+ end
291
+ nil
292
+ end
293
+
212
294
  def taken_rings
213
295
  @taken.values.flat_map(&:rings)
214
296
  end
@@ -260,22 +342,33 @@ module Samagotchi
260
342
  # a later wait on it would repeat its reply.
261
343
  after = after.with(report_claim: claim)
262
344
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
263
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
345
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
346
+ relay: relay?(wait) ? wait.question : nil)
264
347
  rescue ArgumentError
265
348
  nil # the child is gone
266
349
  end
267
350
 
268
351
  # A reply, a turn that left none, a crash, or a question the parent's
269
- # model decides (its own ask_user_question, the step-limit continue).
270
- # Approvals and hooks' questions are the user's, on the child's card.
352
+ # model decides (its own ask_user_question, the step-limit continue, a
353
+ # hook's question). Approvals are the user's (the relay, while the
354
+ # parent waits; else on the child's card).
271
355
  def reportable?(wait)
272
356
  case wait.status
273
357
  when :done, :no_reply, :error then true
274
- when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
358
+ when :waiting_for_answer then REPORTED_KINDS.include?(wait.question&.kind&.to_s)
275
359
  else false
276
360
  end
277
361
  end
278
362
 
363
+ REPORTED_KINDS = [nil, "", "continue", Tools::DelegateRelay::HOOK_KIND].freeze
364
+
365
+ # Whether the child's question goes to this parent's user rather than
366
+ # its model (Tools::DelegateRelay.relay?, under the child's strategy).
367
+ def relay?(wait)
368
+ wait.status == :waiting_for_answer && wait.question.kind.to_s == Tools::DelegateRelay::HOOK_KIND &&
369
+ Tools::DelegateRelay.relay?(wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session))
370
+ end
371
+
279
372
  def delete_rings(rings)
280
373
  rings.each { |file| FileUtils.rm_f(file) }
281
374
  end
@@ -76,8 +76,10 @@ module Samagotchi
76
76
  result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
77
77
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
78
78
  @reported = true
79
+ # What a parent may pick there: config.yml tightened by the session's own.
80
+ strategy = ParentReport.strategy_for(id, state_dir: @state_dir)
79
81
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
80
- json: @json, timeout: timeout, extra: report_extra)
82
+ json: @json, timeout: timeout, extra: report_extra, strategy: strategy)
81
83
  rescue Interrupt
82
84
  if @json
83
85
  @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
@@ -17,7 +17,9 @@ module Samagotchi
17
17
  # but eager parent, not a security boundary.
18
18
  #
19
19
  # Strategies (guardrails.parent_answers, config.yml only): hard = answer
20
- # and deny; standard = + spend; soft (the default) = + permit.
20
+ # and deny; standard = + spend; soft (the default) = + permit. A session
21
+ # may carry its own, stricter one (Session#parent_answers): config.yml
22
+ # is the ceiling (#effective).
21
23
  #
22
24
  # Effects: answer (an untagged option), deny, spend (more time and
23
25
  # tokens: Continue), permit (one call: Allow once), permit_wide (more
@@ -45,16 +47,23 @@ module Samagotchi
45
47
  CONTINUE_QUESTION = PendingQuestion.new(kind: CONTINUE_KIND, options: [CONTINUE, STOP], effects: [SPEND, DENY])
46
48
 
47
49
  KEY = "guardrails.parent_answers"
50
+ # A session's own value (Session#parent_answers): it may only tighten KEY.
51
+ SESSION_KEY = "parent_answers"
48
52
  HARD = "hard"
49
53
  STANDARD = "standard"
50
54
  SOFT = "soft"
51
55
 
52
- # A strategy: its name and the effects a parent may pick under it.
53
- Strategy = Data.define(:name, :allowed) do
56
+ # A strategy: its name, the effects a parent may pick under it, and
57
+ # where it comes from: :config (config.yml's) or :session (a
58
+ # session's own, stricter one: #effective).
59
+ Strategy = Data.define(:name, :allowed, :origin) do
60
+ def initialize(name:, allowed:, origin: :config) = super
61
+
54
62
  def allow?(effect) = allowed.include?(effect.to_s)
55
63
 
56
- # How a refusal names the setting: "guardrails.parent_answers: hard".
57
- def label = "#{KEY}: #{name}"
64
+ # How a refusal names the setting: "guardrails.parent_answers: hard",
65
+ # or "parent_answers: hard (this session)" when the session's binds.
66
+ def label = origin == :session ? "#{SESSION_KEY}: #{name} (this session)" : "#{KEY}: #{name}"
58
67
  end
59
68
 
60
69
  STRATEGIES = {
@@ -74,6 +83,73 @@ module Samagotchi
74
83
  # @return [Strategy]
75
84
  def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
76
85
 
86
+ # Strictest first: a session's own level may only move left of
87
+ # config.yml's.
88
+ ORDER = [HARD, STANDARD, SOFT].freeze
89
+ # A session value that means "none of its own" (config's).
90
+ UNSET = "default"
91
+
92
+ # The stricter of two strategies, by name; a tie keeps +a+.
93
+ # @return [Strategy]
94
+ def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
95
+
96
+ # A session's own value as a level: nil when it has none (nil, "",
97
+ # "default"), the level, or hard for anything else (a hand-edited
98
+ # file: the strictest, as config's invalid value; not logged, as this
99
+ # is read on every parent answer).
100
+ # @return [String, nil]
101
+ def session_level(value)
102
+ word = value.to_s.strip.downcase
103
+ return nil if word.empty? || word == UNSET
104
+
105
+ STRATEGIES.key?(word) ? word : HARD
106
+ end
107
+
108
+ # Whether a session's own value is set but not a level (a hand-edited
109
+ # file: read as hard); the worker says so once when it loads.
110
+ def unknown_level?(value)
111
+ word = value.to_s.strip.downcase
112
+ !word.empty? && word != UNSET && !STRATEGIES.key?(word)
113
+ end
114
+
115
+ # What a parent may answer on a session whose own value is
116
+ # +session_value+: config.yml's (+config+) unless the session's is
117
+ # stricter. Config's own object when it binds, so its label stays.
118
+ # @return [Strategy]
119
+ def effective(session_value, config: current)
120
+ level = session_level(session_value)
121
+ return config if level.nil?
122
+
123
+ own = STRATEGIES[level]
124
+ stricter(config, own).equal?(config) ? config : own.with(origin: :session)
125
+ end
126
+
127
+ # Why a session's own +value+ doesn't apply: "soft is looser than
128
+ # config.yml's guardrails.parent_answers: standard; standard applies";
129
+ # nil when it applies (or is none).
130
+ # @return [String, nil]
131
+ def capped_note(value, config: current)
132
+ level = session_level(value)
133
+ return nil if level.nil? || effective(level, config: config).name == level
134
+
135
+ "#{level} is looser than config.yml's #{config.label}; #{config.name} applies"
136
+ end
137
+
138
+ # #effective with +session+'s own value (a Session; nil: config's).
139
+ # @return [Strategy]
140
+ def for_session(session) = effective(session&.parent_answers)
141
+
142
+ # Whether setting a session's value +from+ → +to+ loosens it: unset
143
+ # (config's) is the loosest, an invalid value hard. Only the user
144
+ # loosens (/parent-answers in a UI).
145
+ def looser?(from, to) = session_rank(to) > session_rank(from)
146
+
147
+ # hard 0, standard 1, soft 2, unset 3.
148
+ def session_rank(value)
149
+ level = session_level(value)
150
+ level.nil? ? ORDER.size : ORDER.index(level)
151
+ end
152
+
77
153
  # @return [Array<String>] the effects +strategy+ lets a parent pick
78
154
  def allowed_effects(strategy) = strategy.allowed
79
155
 
@@ -174,6 +250,19 @@ module Samagotchi
174
250
  end
175
251
  end
176
252
 
253
+ # Whether a parent may usefully answer +pending+ under +strategy+:
254
+ # some option it may pick is not a deny (free text alone is a deny
255
+ # with a reason). A delegate's question it may is reported to the
256
+ # parent's model; one it may not goes to the parent's user
257
+ # (Tools::DelegateRelay.relay?). Who may answer what stays #refusal's.
258
+ def parent_decides?(pending, strategy:)
259
+ deny = deny_labels(pending)
260
+ allowed_options(pending, strategy: strategy).any? { |label| !deny.include?(label) }
261
+ end
262
+
263
+ # The deny-tagged options' labels.
264
+ def deny_labels(pending) = Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == DENY }
265
+
177
266
  # The question's deny-tagged option's label, or nil.
178
267
  def deny_label(pending)
179
268
  index = effects_of(pending).index(DENY)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "guardrails/parent_policy"
4
+
5
+ module Samagotchi
6
+ # /parent-answers, the session's own guardrails.parent_answers
7
+ # (Session#parent_answers): with no argument, the level that applies, the
8
+ # session's own and config.yml's; `/parent-answers LEVEL` (hard, standard,
9
+ # soft) or `default` (none of its own) sets it, saved at once, for the next
10
+ # answer a parent gives. It may only tighten config.yml's
11
+ # (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
12
+ # loosening (toward soft, or default) only from a user at a UI: the REPL,
13
+ # an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
14
+ # a piped or marked attach, a delegate's follow-up) is refused.
15
+ class ParentAnswersCommand
16
+ NAME = "/parent-answers"
17
+ USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
18
+ REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
19
+ "(not chi send or a parent agent)".freeze
20
+
21
+ # What +args+ (the text after the name) asks for.
22
+ # @return [Array] [:show], [:set, level or nil (default)] or [:usage]
23
+ def self.parse(args)
24
+ words = args.to_s.split
25
+ return [:show] if words.empty?
26
+ return [:usage] if words.size > 1
27
+
28
+ word = words.first.downcase
29
+ return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
30
+
31
+ Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
32
+ end
33
+
34
+ # Whether the command line +line+ loosens a session whose own value is
35
+ # +current+ (chi send's early refusal, before it sends the line).
36
+ def self.loosens?(line, current)
37
+ kind, value = parse(line.to_s.strip.delete_prefix(NAME))
38
+ kind == :set && Guardrails::ParentPolicy.looser?(current, value)
39
+ end
40
+
41
+ # @param engine [Engine] #session
42
+ # @param save [#call] saves the session after a change
43
+ def initialize(engine:, save:)
44
+ @engine = engine
45
+ @save = save
46
+ end
47
+
48
+ # @param args [String] what follows /parent-answers
49
+ # @param user_ui [Boolean] a user at a UI typed it: it may loosen
50
+ # @return [Array(String, Boolean, Symbol)] the reply, whether the
51
+ # session's value changed, and :ok or :error
52
+ def run(args, user_ui:)
53
+ session = @engine.session or return ["no session yet", false, :error]
54
+ kind, value = self.class.parse(args)
55
+ return [USAGE, false, :error] if kind == :usage
56
+ return [listing(session), false, :ok] if kind == :show
57
+ return ["#{listing(session)}\n(no change)", false, :ok] if value == session.parent_answers
58
+ return [REFUSED, false, :error] if Guardrails::ParentPolicy.looser?(session.parent_answers, value) && !user_ui
59
+
60
+ session.parent_answers = value
61
+ @save.call(session)
62
+ capped = Guardrails::ParentPolicy.capped_note(value)
63
+ [[listing(session), ("(#{capped})" if capped)].compact.join("\n"), true, :ok]
64
+ end
65
+
66
+ private
67
+
68
+ # "parent answers: standard (this session: standard; config.yml
69
+ # guardrails.parent_answers: soft)"
70
+ def listing(session)
71
+ config = Guardrails::ParentPolicy.current
72
+ effective = Guardrails::ParentPolicy.for_session(session)
73
+ own = session.parent_answers || "none"
74
+ # A hand-edited value that isn't a level: fail-closed, said here.
75
+ own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
76
+ !Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
77
+ "parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
78
+ end
79
+ end
80
+ end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "cli/exit"
7
7
  require_relative "pending_question"
8
+ require_relative "session"
8
9
  require_relative "guardrails/parent_policy"
9
10
 
10
11
  module Samagotchi
@@ -77,18 +78,20 @@ module Samagotchi
77
78
  # @param extra [Hash] more keys for the object (chi answer's
78
79
  # answered_here: false)
79
80
  # @return [Hash] the --format json object
80
- def json(result, session_id:, timeout: nil, extra: {})
81
+ # @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
82
+ # may pick on the waited-for session (#strategy_for); nil: config.yml's
83
+ def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
81
84
  report = { status: status(result), session_id: session_id }
82
85
  report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
83
86
  case report[:status]
84
87
  when "answered" then report[:text] = result.text.to_s
85
88
  when "question"
86
- question = question(result.question)
89
+ question = question(result.question, strategy: strategy)
87
90
  report[:question] = question
88
91
  report[:answer_with] = answer_with(session_id, question)
89
92
  else
90
93
  report.merge!(stop_facts(result))
91
- report[:detail] = detail(result, session_id: session_id, timeout: timeout)
94
+ report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
92
95
  end
93
96
  report.merge(extra)
94
97
  end
@@ -106,8 +109,8 @@ module Samagotchi
106
109
  end
107
110
 
108
111
  # @return [String] the JSON object as one line
109
- def json_line(result, session_id:, timeout: nil, extra: {})
110
- JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
112
+ def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
113
+ JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
111
114
  end
112
115
 
113
116
  # Print a wait's end the way the commands do: the JSON object on
@@ -116,17 +119,17 @@ module Samagotchi
116
119
  # @param command [String] "chi send", "chi answer"
117
120
  # @param extra [Hash] more keys for the JSON object (#json)
118
121
  # @return [Integer] the exit status
119
- def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
122
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
120
123
  if json
121
- stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
124
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
122
125
  elsif result.status == :done
123
126
  stdout.puts(result.text)
124
127
  else
125
128
  stdout.flush
126
129
  text = if result.status == :waiting_for_answer
127
- question_text(result.question, session_id: session_id)
130
+ question_text(result.question, session_id: session_id, strategy: strategy)
128
131
  else
129
- "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
132
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
130
133
  end
131
134
  stderr.print("#{command}: #{text}")
132
135
  end
@@ -146,14 +149,14 @@ module Samagotchi
146
149
  # kind is "question" for the model's own (the desk stores none),
147
150
  # "hook", "approval" or "continue" (the step-limit question, with the
148
151
  # turn's limit). allowed_options: the options a parent may pick, by
149
- # this process's guardrails.parent_answers (Guardrails::ParentPolicy),
150
- # when that is not all of them.
152
+ # +strategy+ (nil: this process's guardrails.parent_answers;
153
+ # Guardrails::ParentPolicy), when that is not all of them.
151
154
  # @return [Hash]
152
- def question(pending)
155
+ def question(pending, strategy: nil)
153
156
  pending = PendingQuestion.from_file(pending) || PendingQuestion.new
154
157
  approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
155
158
  options = Array(pending.options).map(&:to_s)
156
- allowed = allowed_options(pending)
159
+ allowed = allowed_options(pending, strategy: strategy)
157
160
  {
158
161
  id: pending.id.to_s,
159
162
  kind: (pending.kind || "question").to_s,
@@ -189,33 +192,49 @@ module Samagotchi
189
192
  DENY_AND_TELL = "deny it, and tell your user"
190
193
 
191
194
  # The labels a parent may pick on +pending+ (a PendingQuestion, or a
192
- # Hash with its fields) by this process's strategy.
195
+ # Hash with its fields) by +strategy+ (nil: this process's).
193
196
  # @return [Array<String>]
194
- def allowed_options(pending)
197
+ def allowed_options(pending, strategy: nil)
195
198
  pending = PendingQuestion.from_file(pending) || PendingQuestion.new
196
- Guardrails::ParentPolicy.allowed_options(pending, strategy: Guardrails::ParentPolicy.current)
199
+ Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
197
200
  end
198
201
 
202
+ # What a parent may pick on the session +session_id+: config.yml's
203
+ # level tightened by the session file's own parent_answers
204
+ # (Guardrails::ParentPolicy.for_session); config.yml's when there is no
205
+ # such session, hard (the strictest) when its file can't be read.
206
+ # @return [Guardrails::ParentPolicy::Strategy]
207
+ def strategy_for(session_id, state_dir:)
208
+ return Guardrails::ParentPolicy.current unless Session.exist?(session_id, state_dir: state_dir)
209
+
210
+ Guardrails::ParentPolicy.for_session(Session.load(session_id, state_dir: state_dir))
211
+ rescue ArgumentError, KeyError, TypeError, NoMethodError, SystemCallError, JSON::ParserError
212
+ Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
213
+ end
214
+
215
+ # +strategy+, or this process's config.yml's.
216
+ def policy(strategy) = strategy || Guardrails::ParentPolicy.current
217
+
199
218
  # An approval's options a parent may allow (Allow once under soft).
200
- def allowed_allows(question)
201
- question = question(question) unless question.is_a?(Hash) && question.key?(:text)
219
+ def allowed_allows(question, strategy: nil)
220
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
202
221
  deny = Guardrails::Approval::DENY
203
222
  question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
204
223
  end
205
224
 
206
225
  # What a parent does with an approval: deny it and tell its user, or
207
226
  # give the Allow once its strategy lets it.
208
- def approval_advice(question)
209
- allows = allowed_allows(question)
227
+ def approval_advice(question, strategy: nil)
228
+ allows = allowed_allows(question, strategy: strategy)
210
229
  return DENY_AND_TELL if allows.empty?
211
230
 
212
- "#{allows.join(" or ")} is yours to give (#{Guardrails::ParentPolicy.current.label}); " \
231
+ "#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
213
232
  "anything wider is up to your user: #{DENY_AND_TELL}"
214
233
  end
215
234
 
216
235
  # Whether a parent may answer Continue to a step-limit question.
217
- def may_continue?(question)
218
- question = question(question) unless question.is_a?(Hash) && question.key?(:text)
236
+ def may_continue?(question, strategy: nil)
237
+ question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
219
238
  question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
220
239
  end
221
240
 
@@ -252,23 +271,23 @@ module Samagotchi
252
271
  end
253
272
 
254
273
  # What a wait that ended without an answer says, one line.
255
- def detail(result, session_id:, timeout: nil, turn_id: nil)
274
+ def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
256
275
  attach = "chi --attach #{session_id}"
257
276
  case result.status
258
277
  when :waiting_for_answer
259
- q = question(result.question)
278
+ q = question(result.question, strategy: strategy)
260
279
  if approval?(q)
261
- "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q)}"
280
+ "waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
262
281
  elsif continue?(q) && !may_continue?(q)
263
282
  "waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
264
- "(#{Guardrails::ParentPolicy.current.label}): #{stop_command(session_id, q)}, or tell your user it waits"
283
+ "(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
265
284
  elsif continue?(q)
266
285
  "waiting at the step limit: #{first_line(q[:text])}; " \
267
286
  "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
268
287
  else
269
288
  "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
270
289
  end
271
- when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
290
+ when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
272
291
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
273
292
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
274
293
  when :stopped then "the session was stopped (chi sessions stop)"
@@ -282,8 +301,8 @@ module Samagotchi
282
301
  # The whole question for stderr: header, text, numbered options, what
283
302
  # else it takes, and the commands that answer it.
284
303
  # @return [String] lines, newline-terminated
285
- def question_text(pending, session_id:)
286
- q = question(pending)
304
+ def question_text(pending, session_id:, strategy: nil)
305
+ q = question(pending, strategy: strategy)
287
306
  lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
288
307
  text_lines = q[:text].strip.lines.map(&:rstrip)
289
308
  text_lines = text_lines.drop(1) unless q[:header]
@@ -294,7 +313,7 @@ module Samagotchi
294
313
  if allows.empty?
295
314
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
296
315
  else
297
- lines << " #{approval_advice(q)}"
316
+ lines << " #{approval_advice(q, strategy: strategy)}"
298
317
  allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
299
318
  end
300
319
  lines << " deny: #{answer_with(session_id, q)}"
@@ -303,7 +322,7 @@ module Samagotchi
303
322
  lines << if may_continue?(q)
304
323
  " continue: #{answer_with(session_id, q)}"
305
324
  else
306
- " continuing it is up to your user (#{Guardrails::ParentPolicy.current.label}): stop it, or tell your user it waits"
325
+ " continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
307
326
  end
308
327
  lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
309
328
  lines << " #{message_drops(session_id)}"
@@ -319,7 +338,7 @@ module Samagotchi
319
338
  lines.map { |line| "#{line}\n" }.join
320
339
  end
321
340
 
322
- def no_reply_line(result, session_id = nil)
341
+ def no_reply_line(result, session_id = nil, strategy: nil)
323
342
  case result.outcome
324
343
  when "failed" then failed_line(result)
325
344
  when "canceled"
@@ -330,7 +349,7 @@ module Samagotchi
330
349
  when "removed" then "the queued message was removed before it ran; no turn ran for it"
331
350
  when "exhausted"
332
351
  ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
333
- strategy = Guardrails::ParentPolicy.current
352
+ strategy = policy(strategy)
334
353
  if strategy.allow?(Guardrails::ParentPolicy::SPEND)
335
354
  "#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
336
355
  else
@@ -305,8 +305,9 @@ module Samagotchi
305
305
  fm = freeform.to_s.strip
306
306
  fm = nil if fm.empty?
307
307
  parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
308
- # Read before the lock (config may touch the disk); this worker's own.
309
- strategy = Guardrails::ParentPolicy.current if parent_agent
308
+ # Read before the lock (config may touch the disk); this worker's own
309
+ # config.yml, tightened by its session's own parent_answers.
310
+ strategy = Guardrails::ParentPolicy.for_session(@session_lookup.call) if parent_agent
310
311
  standing = nil
311
312
  answer = @lock.synchronize do
312
313
  pending = @pending