samagotchi 0.107.0 → 0.109.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/configuration.md +81 -0
  5. data/docs/internals/engine.md +17 -6
  6. data/docs/internals/participants.md +28 -8
  7. data/docs/internals/remote-sessions.md +6 -2
  8. data/docs/internals/working-set.md +2 -1
  9. data/docs/memory.md +20 -0
  10. data/docs/plugins.md +9 -2
  11. data/docs/releasing.md +11 -6
  12. data/docs/sessions.md +3 -3
  13. data/docs/sub-agent.md +1 -1
  14. data/docs/testing.md +5 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  16. data/lib/samagotchi/bridge.rb +28 -2
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  23. data/lib/samagotchi/child_reports.rb +2 -1
  24. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  25. data/lib/samagotchi/config.rb +4 -0
  26. data/lib/samagotchi/context_sources.rb +1 -6
  27. data/lib/samagotchi/continue_offer.rb +8 -6
  28. data/lib/samagotchi/engine.rb +36 -25
  29. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  30. data/lib/samagotchi/idle_scheduler.rb +17 -2
  31. data/lib/samagotchi/last_turn.rb +7 -0
  32. data/lib/samagotchi/log_subscriber.rb +2 -1
  33. data/lib/samagotchi/memory_orphans.rb +74 -0
  34. data/lib/samagotchi/memory_paths.rb +4 -4
  35. data/lib/samagotchi/parent_report.rb +11 -3
  36. data/lib/samagotchi/proxy_trace.rb +45 -0
  37. data/lib/samagotchi/reply_wait.rb +7 -2
  38. data/lib/samagotchi/send_command/busy.rb +5 -2
  39. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  40. data/lib/samagotchi/session_access/local.rb +46 -10
  41. data/lib/samagotchi/session_inbox.rb +12 -8
  42. data/lib/samagotchi/session_metrics.rb +7 -6
  43. data/lib/samagotchi/session_participants.rb +3 -1
  44. data/lib/samagotchi/steer.rb +11 -1
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  46. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  47. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  48. data/lib/samagotchi/tools/memory.rb +16 -9
  49. data/lib/samagotchi/turn_origin.rb +68 -0
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +233 -26
  52. data/lib/samagotchi/web/grants.rb +8 -4
  53. data/lib/samagotchi/web/info_probe.rb +5 -1
  54. data/lib/samagotchi/web/link_codes.rb +146 -0
  55. data/lib/samagotchi/web/participant_access.rb +15 -118
  56. data/lib/samagotchi/web/participant_review.rb +1 -1
  57. data/lib/samagotchi/web/participant_view.rb +7 -13
  58. data/lib/samagotchi/web/participants_admin.rb +136 -0
  59. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  60. data/lib/samagotchi/web/public/app.js +24 -0
  61. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  62. data/lib/samagotchi/web/public/data.js +29 -5
  63. data/lib/samagotchi/web/public/participant.js +19 -5
  64. data/lib/samagotchi/web/public/participant_model.js +5 -0
  65. data/lib/samagotchi/web/public/participant_names.js +2 -2
  66. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  67. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  68. data/lib/samagotchi/web/public_url.rb +87 -0
  69. data/lib/samagotchi/web/server.rb +70 -10
  70. data/lib/samagotchi/worker.rb +30 -25
  71. data/lib/samagotchi/worker_inbound.rb +12 -9
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. data/lib/samagotchi/working_set.rb +3 -2
  74. metadata +10 -1
@@ -5,6 +5,7 @@ require_relative "../kernel_loop"
5
5
  require_relative "../steer"
6
6
  require_relative "../tool_output_cut"
7
7
  require_relative "../tool_row_fields"
8
+ require_relative "../turn_origin"
8
9
  require_relative "turn_notice"
9
10
 
10
11
  module Samagotchi
@@ -70,7 +71,7 @@ module Samagotchi
70
71
  # Whether the turn in progress is a participant's review turn (its
71
72
  # author): the owner's input queues behind it then (Bridge's POST /turn).
72
73
  def participant_turn?
73
- @mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || @turn.dig(:origin, :author)) }
74
+ @mutex.synchronize { !@turn.nil? && Steer.participant_author?(@turn[:author] || TurnOrigin.from(@turn[:origin])&.author) }
74
75
  end
75
76
 
76
77
  # The turn in progress as conversation messages, for a plugin's
@@ -382,7 +383,7 @@ module Samagotchi
382
383
  end
383
384
 
384
385
  def dequeue(origins)
385
- ids = origins.filter_map { |origin| origin.is_a?(Hash) ? origin[:enqueued_id] : nil }
386
+ ids = origins.filter_map { |origin| TurnOrigin.from(origin)&.enqueued_id }
386
387
  @queued.reject! { |entry| ids.include?(entry[:enqueued_id]) } unless ids.empty?
387
388
  end
388
389
  end
@@ -22,9 +22,11 @@ require_relative "engine"
22
22
  require_relative "session_commands"
23
23
  require_relative "queued_command"
24
24
  require_relative "steer"
25
+ require_relative "turn_origin"
25
26
  require_relative "participants"
26
27
  require_relative "image_store"
27
28
  require_relative "log"
29
+ require_relative "proxy_trace"
28
30
  require_relative "version"
29
31
  require_relative "worker_sidecar"
30
32
  require_relative "relay_verifier"
@@ -119,6 +121,8 @@ module Samagotchi
119
121
  @connection_threads = []
120
122
  @stopped = false
121
123
  @mutex = Monitor.new
124
+ @proxied_log_mutex = Mutex.new
125
+ @proxied_logged_at = nil
122
126
  @open_streams = 0
123
127
  # Open streams by the client_id a stream named (`?client_id=`); web
124
128
  # tabs, through the `chi web` proxy, name none.
@@ -437,6 +441,10 @@ module Samagotchi
437
441
  break
438
442
  elsif browser_request?(headers)
439
443
  write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
444
+ elsif proxied_request?(headers)
445
+ note_proxied_refused(request[:path])
446
+ write_json(io, 403, nil, { error: "proxied", detail: "the bridge answers chi's own clients on this machine, " \
447
+ "never a request through a proxy" })
440
448
  elsif route.nil?
441
449
  write_json(io, 404, { "Allow" => "GET, POST" },
442
450
  { error: "not_found", path: request[:path] })
@@ -523,6 +531,24 @@ module Samagotchi
523
531
  # no Sec-Fetch-Site. A browser page always sends one of them (another
524
532
  # website's text/plain POST needs no preflight), and a DNS-rebound page
525
533
  # also has a foreign Host. Headers are the raw ones, lowercased.
534
+ # A request through a proxy (an auth proxy forwarding every port, as a
535
+ # work pod's does) carries a trace chi's own clients never send
536
+ # (ProxyTrace: X-Forwarded-*, Forwarded, X-Real-IP, X-Auth-Request-*…,
537
+ # underscore twins too): refused, whoever signed in to the proxy. chi
538
+ # web's stream proxy forwards Last-Event-ID only (ProxyStreamBody).
539
+ def proxied_request?(headers) = headers.keys.any? { |name| ProxyTrace.header?(name) }
540
+
541
+ PROXIED_LOG_INTERVAL = 60.0
542
+
543
+ # At most once a minute: a scan through a forward would fill the log.
544
+ def note_proxied_refused(path)
545
+ now = monotonic_now
546
+ due = @proxied_log_mutex.synchronize do
547
+ (@proxied_logged_at.nil? || now - @proxied_logged_at >= PROXIED_LOG_INTERVAL).tap { |fresh| @proxied_logged_at = now if fresh }
548
+ end
549
+ Log.warn(:bridge, "proxied_refused", path: path) if due
550
+ end
551
+
526
552
  def browser_request?(headers)
527
553
  return true if headers.key?("origin")
528
554
  return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
@@ -1032,14 +1058,14 @@ module Samagotchi
1032
1058
  # +author+'s turns running or waiting: the running turn when it is
1033
1059
  # theirs, and their queued ones. Read with the event log held.
1034
1060
  def pending_turns(author)
1035
- running = Steer.sender_author(@accumulator.current_turn&.dig(:origin, :author)) == author ? 1 : 0
1061
+ running = TurnOrigin.of(@accumulator.current_turn&.dig(:origin)).participant_author == author ? 1 : 0
1036
1062
  running + @accumulator.queued.count { |row| row[:author] == author }
1037
1063
  end
1038
1064
 
1039
1065
  # 403 not_permitted for a participant's +what+ (a command, a shell
1040
1066
  # line), with its audit line.
1041
1067
  def participant_refused(sid, author, what)
1042
- Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: author.delete_prefix("p:"), what: what,
1068
+ Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: Steer.participant_id(author), what: what,
1043
1069
  reason: "not_permitted")
1044
1070
  [{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
1045
1071
  end
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: cost-cap
3
- version: 0.2.1
3
+ version: 0.2.3
4
4
  scope: system
5
5
  description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:28d632cd19117ef359acc6aa1c621081c14c018772b7fe1efd6284e346fea135
9
+ sha256: sha256:57362669f6b499fb70103a11eb6582e261b69103f51ee1ac36496a04717f5d9b
10
10
  # ctx.cost is new in chi 0.83.0; ask_user's effects: in the next chi
11
11
  requires_chi: ">= 0.92.0"
12
12
  # session_settings: as a mapping of keys to types is new
@@ -44,6 +44,7 @@ class Plugin
44
44
  @warned = nil # [session id, cap] the share warning was given for
45
45
  @unknown_warned = nil # the session id the "can't be enforced" notice was given for
46
46
  @unread_warned = nil # the session id the "isn't a number" notice was given for
47
+ @corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
47
48
  end
48
49
 
49
50
  def register(chi)
@@ -104,6 +105,7 @@ class Plugin
104
105
  def check(ctx)
105
106
  resolved = resolve(ctx)
106
107
  unreadable(ctx, resolved)
108
+ corrupt(ctx, resolved)
107
109
  cap = resolved[:cap]
108
110
  return unless cap
109
111
 
@@ -140,10 +142,23 @@ class Plugin
140
142
  return if bad.nil? || bad.empty? || !first_time?(:@unread_warned, resolved[:session])
141
143
 
142
144
  named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
143
- used = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
144
- ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{used}", level: :warn)
145
+ ctx.notify("cost-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
146
+ level: :warn)
145
147
  end
146
148
 
149
+ # Once per session: a state file that is there but doesn't read (not
150
+ # JSON, or not a mapping) holds no /cap cap, so the settings' is used;
151
+ # said rather than a /cap cap silently gone.
152
+ def corrupt(ctx, resolved)
153
+ path = resolved[:corrupt]
154
+ return unless path && first_time?(:@corrupt_warned, resolved[:session])
155
+
156
+ ctx.notify("cost-cap: #{path} doesn't read, its /cap cap is ignored; #{using(resolved)}", level: :warn)
157
+ end
158
+
159
+ # "using default: ($5.00)", or "no cap".
160
+ def using(resolved) = resolved[:cap] ? "using #{resolved[:key]}: (#{money(resolved[:cap])})" : "no cap"
161
+
147
162
  def at_cap(ctx, resolved, cost)
148
163
  cap = resolved[:cap]
149
164
  raised = raised_cap(cap, resolved[:step] || cap, cost.total)
@@ -222,20 +237,29 @@ class Plugin
222
237
  cached = @mutex.synchronize { @resolved }
223
238
  return cached if cached && cached[:session] == session
224
239
 
225
- resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
240
+ saved, corrupt = state(ctx, session)
241
+ resolved = { session: session, **(saved || configured(ctx)), corrupt: corrupt }
226
242
  @mutex.synchronize { @resolved = resolved }
227
243
  end
228
244
 
245
+ # The state file's cap and step ({cap:, step:}), nil without one (or
246
+ # one without a "cap"), and the file's path when it is there but
247
+ # doesn't read (logged; the user is told by #corrupt), else nil.
229
248
  def state(ctx, session)
230
- return nil unless session
249
+ return [nil, nil] unless session
231
250
 
232
- saved = JSON.parse(File.read(state_path(ctx, session)))
233
- return nil unless saved.is_a?(Hash) && saved.key?("cap")
251
+ path = state_path(ctx, session)
252
+ saved = JSON.parse(File.read(path))
253
+ raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
254
+ return [nil, nil] unless saved.key?("cap")
234
255
 
235
256
  cap = self.class.amount(saved["cap"])
236
- { cap: cap, step: self.class.amount(saved["step"]) || cap }
237
- rescue Errno::ENOENT, JSON::ParserError
238
- nil
257
+ [{ cap: cap, step: self.class.amount(saved["step"]) || cap }, nil]
258
+ rescue Errno::ENOENT
259
+ [nil, nil]
260
+ rescue JSON::ParserError => e
261
+ ctx.log.warn(:costcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
262
+ [nil, path]
239
263
  end
240
264
 
241
265
  # The first of cap:, child: (a delegate's) and default: that is set and
@@ -259,14 +283,32 @@ class Plugin
259
283
  share.is_a?(Numeric) && share.positive? ? share : nil
260
284
  end
261
285
 
286
+ # A state file there that doesn't read (#state) is written over: logged
287
+ # and said, never a silent replace.
262
288
  def save(ctx, session, data)
263
289
  path = state_path(ctx, session)
264
290
  FileUtils.mkdir_p(File.dirname(path))
291
+ replacing = unreadable_file?(path)
265
292
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
293
+ replaced(ctx, path) if replacing
266
294
  cap = self.class.amount(data["cap"])
267
295
  @mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
268
296
  end
269
297
 
298
+ # Whether +path+ is there but doesn't read (not JSON, or not a mapping).
299
+ def unreadable_file?(path)
300
+ !JSON.parse(File.read(path)).is_a?(Hash)
301
+ rescue Errno::ENOENT
302
+ false
303
+ rescue JSON::ParserError
304
+ true
305
+ end
306
+
307
+ def replaced(ctx, path)
308
+ ctx.log.warn(:costcap_state_replaced, path: path)
309
+ ctx.notify("cost-cap: #{path} didn't read; replaced with the new cap", level: :warn)
310
+ end
311
+
270
312
  def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
271
313
 
272
314
  def money(value) = self.class.money(value)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.107.0
3
+ version: 0.109.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
9
9
  config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
- memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
10
+ memory_guide.md: sha256:dfb3982cf281bf7920d018ea65b68b5755f86d8bd1e03bab9db42393a94ec7e0
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -26,7 +26,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
26
26
  - `name: "entry", scope: "system"|"project"` — scoped read.
27
27
  - `name: "a, b, c"` — comma-separated, concatenated with `\n\n---\n\n`.
28
28
  - `name: ""` (optionally with `scope`) — reads the index. Empty name with no scope returns both indexes concatenated (`Project memories:` / `System memories:`). Use this at start to discover what exists.
29
- - Returns `Error: memory not found: …` on miss — treat as missing, not fatal.
29
+ - Returns `Error: memory not found: …` on miss — treat as missing, not fatal. A `Hint:` line after it means this project's memory folder looks orphaned (moved from another path); tell the user the folders it names, don't rename them yourself.
30
30
 
31
31
  ### `memory_write` — write/update entries
32
32
 
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: work-cap
3
- version: 0.1.1
3
+ version: 0.1.3
4
4
  scope: system
5
5
  description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:f4ce7ea7f5801490a29e232c41c7bca2007c140d21f1a78bbfb60e6b6fd4f64b
9
+ sha256: sha256:3c93baec9ec20e147803311e1eb16d699dc169b52680374c1e6ff865a82204ee
10
10
  # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
11
  requires_chi: ">= 0.100.0"
12
12
  # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
@@ -63,6 +63,7 @@ class Plugin
63
63
  @resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
64
64
  @warned = {} # meter => [session id, cap] the share warning was given for
65
65
  @unread_warned = nil # the session id the "isn't a number" notice was given for
66
+ @corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
66
67
  end
67
68
 
68
69
  def register(chi)
@@ -126,6 +127,7 @@ class Plugin
126
127
  def check(ctx)
127
128
  resolved = resolve(ctx)
128
129
  unreadable(ctx, resolved)
130
+ corrupt(ctx, resolved)
129
131
  capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
130
132
  return if capped.empty?
131
133
 
@@ -161,11 +163,26 @@ class Plugin
161
163
  return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
162
164
 
163
165
  named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
166
+ ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
167
+ level: :warn)
168
+ end
169
+
170
+ # Once per session: a state file that is there but doesn't read (not
171
+ # JSON, or not a mapping) holds no /workcap caps, so the settings' are
172
+ # used; said rather than a /workcap cap silently gone.
173
+ def corrupt(ctx, resolved)
174
+ path = resolved[:corrupt]
175
+ return unless path && first_time?(:@corrupt_warned, resolved[:session])
176
+
177
+ ctx.notify("work-cap: #{path} doesn't read, its /workcap caps are ignored; #{using(resolved)}", level: :warn)
178
+ end
179
+
180
+ # "using tool_calls: (400), minutes: (60)", or "no cap".
181
+ def using(resolved)
164
182
  used = resolved[:meters].values.filter_map do |meter_cap|
165
183
  "#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
166
184
  end
167
- using = used.empty? ? "no cap" : "using #{used.join(", ")}"
168
- ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
185
+ used.empty? ? "no cap" : "using #{used.join(", ")}"
169
186
  end
170
187
 
171
188
  def at_cap(ctx, resolved, reached, usage, used)
@@ -280,29 +297,37 @@ class Plugin
280
297
  cached = @mutex.synchronize { @resolved }
281
298
  return cached if cached && cached[:session] == session && cached[:settings] == settings
282
299
 
283
- saved = state(ctx, session)
300
+ saved, corrupt = state(ctx, session)
284
301
  configured = configured(ctx)
285
302
  meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
286
- resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
303
+ resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable],
304
+ corrupt: corrupt }
287
305
  @mutex.synchronize { @resolved = resolved }
288
306
  end
289
307
 
290
- # The state file's meters ({meter => {cap:, step:}}), {} without one.
308
+ # The state file's meters ({meter => {cap:, step:}}), {} without one,
309
+ # and the file's path when it is there but doesn't read (logged; the
310
+ # user is told by #corrupt), else nil.
291
311
  def state(ctx, session)
292
- return {} unless session
312
+ return [{}, nil] unless session
293
313
 
294
- saved = JSON.parse(File.read(state_path(ctx, session)))
295
- return {} unless saved.is_a?(Hash)
314
+ path = state_path(ctx, session)
315
+ saved = JSON.parse(File.read(path))
316
+ raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
296
317
 
297
- METERS.each_with_object({}) do |meter, meters|
318
+ meters = METERS.each_with_object({}) do |meter, found|
298
319
  entry = saved[meter]
299
320
  next unless entry.is_a?(Hash) && entry.key?("cap")
300
321
 
301
322
  cap = self.class.amount(entry["cap"], meter)
302
- meters[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
323
+ found[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
303
324
  end
304
- rescue Errno::ENOENT, JSON::ParserError
305
- {}
325
+ [meters, nil]
326
+ rescue Errno::ENOENT
327
+ [{}, nil]
328
+ rescue JSON::ParserError => e
329
+ ctx.log.warn(:workcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
330
+ [{}, path]
306
331
  end
307
332
 
308
333
  # Per meter the first of its own value, child:'s (a delegate's) and
@@ -358,21 +383,32 @@ class Plugin
358
383
  end
359
384
 
360
385
  # Write one meter's cap into the session's state file (the other kept).
386
+ # One there that doesn't read (#state) is written over: logged and said,
387
+ # never a silent replace.
361
388
  def save_meter(ctx, session, meter, data)
362
389
  path = state_path(ctx, session)
363
390
  FileUtils.mkdir_p(File.dirname(path))
364
391
  saved = begin
365
392
  JSON.parse(File.read(path))
366
- rescue Errno::ENOENT, JSON::ParserError
393
+ rescue Errno::ENOENT
367
394
  {}
395
+ rescue JSON::ParserError
396
+ nil
368
397
  end
369
- saved = {} unless saved.is_a?(Hash)
398
+ replacing = !saved.is_a?(Hash)
399
+ saved = {} if replacing
370
400
  saved[meter] = data.transform_values { |value| whole(value) }
371
401
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
402
+ replaced(ctx, path) if replacing
372
403
  # Resolved again at the next use, from the file now holding the meter.
373
404
  @mutex.synchronize { @resolved = nil }
374
405
  end
375
406
 
407
+ def replaced(ctx, path)
408
+ ctx.log.warn(:workcap_state_replaced, path: path)
409
+ ctx.notify("work-cap: #{path} didn't read; replaced with the new cap", level: :warn)
410
+ end
411
+
376
412
  # 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
377
413
  def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
378
414
 
@@ -7,6 +7,7 @@ require_relative "session"
7
7
  require_relative "session_inbox"
8
8
  require_relative "reply_wait"
9
9
  require_relative "steer"
10
+ require_relative "turn_origin"
10
11
  require_relative "log"
11
12
  require_relative "config"
12
13
  require_relative "tools/delegate_wait"
@@ -138,7 +139,7 @@ module Samagotchi
138
139
  def initialize(relay: nil, **) = super
139
140
 
140
141
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
141
- def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
142
+ def origin = TurnOrigin.new(client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}")
142
143
  end
143
144
 
144
145
  # @param predecessor [String, nil] the link this session continues
@@ -96,7 +96,7 @@ module Samagotchi
96
96
  @stdout.flush
97
97
  @reported = true
98
98
  end
99
- error_line("#{command_name}: still running: #{@access.invocation.shows(id)}")
99
+ error_line("#{command_name}: still running: #{ParentReport.still_running_hint(id, report_turn_id, via: @access.invocation)}")
100
100
  Exit::INTERRUPTED
101
101
  rescue ArgumentError
102
102
  error_line("#{command_name}: the session is gone (deleted while waiting)")
@@ -316,6 +316,10 @@ module Samagotchi
316
316
  Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
317
317
  Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
318
318
  Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
319
+ # chi web behind an auth proxy (Web::PublicUrl, docs/configuration.md "Behind an auth proxy").
320
+ Entry.new(key: "web.public_url", yaml_path: %w[web public_url], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
321
+ Entry.new(key: "web.proxy.identity_header", yaml_path: %w[web proxy identity_header], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
322
+ Entry.new(key: "web.proxy.owner", yaml_path: %w[web proxy owner], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
319
323
 
320
324
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
321
325
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
@@ -359,12 +359,7 @@ module Samagotchi
359
359
  def project_location_for(project_root, state_dir: nil)
360
360
  return nil if project_root.to_s.empty?
361
361
 
362
- project_location(project_key_of(project_root), state_dir: state_dir)
363
- end
364
-
365
- # MemoryPaths.project_key for a root already resolved.
366
- def project_key_of(root)
367
- "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
362
+ project_location(MemoryPaths.key_for_root(project_root), state_dir: state_dir)
368
363
  end
369
364
 
370
365
  # A session's sources: its own, then the project's it doesn't shadow
@@ -4,6 +4,7 @@ require "time"
4
4
  require_relative "last_turn"
5
5
  require_relative "log"
6
6
  require_relative "steer"
7
+ require_relative "turn_origin"
7
8
 
8
9
  module Samagotchi
9
10
  # A session worker's continue offer: what it announces when a turn runs out
@@ -71,6 +72,7 @@ module Samagotchi
71
72
 
72
73
  # A prompt (or a reminder turn) taken while a continue is offered
73
74
  # replaces the answer (D2): the offer goes, the partial turn stays.
75
+ # @param origin [TurnOrigin] the taken turn's
74
76
  def drop(origin)
75
77
  return unless awaiting?
76
78
 
@@ -78,7 +80,7 @@ module Samagotchi
78
80
  close("dropped")
79
81
  @engine.synchronize_events do
80
82
  @turn_flow.drop_offer!
81
- @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
83
+ @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin.client_id)
82
84
  end
83
85
  @engine.record_activity
84
86
  end
@@ -149,7 +151,7 @@ module Samagotchi
149
151
  offer = @turn_flow.offer
150
152
  resume_from = @turn_flow.resume_from
151
153
  enqueued_id = offer_turn_id
152
- author = Steer.sender_author(@engine.session&.last_turn&.author)
154
+ author = @engine.session&.last_turn&.participant_author
153
155
  close("answered")
154
156
  if author && (reason = @participant_refused.call(author))
155
157
  @continue_steer = nil
@@ -166,7 +168,7 @@ module Samagotchi
166
168
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
167
169
  return announce_offer(offer)
168
170
  end
169
- origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
171
+ origin = TurnOrigin.new(client_id: command.client_id, enqueued_id: enqueued_id, author: author)
170
172
  @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
171
173
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
172
174
  if error
@@ -192,7 +194,7 @@ module Samagotchi
192
194
  private
193
195
 
194
196
  def refusal_of_offer_author
195
- author = Steer.sender_author(@engine.session&.last_turn&.author)
197
+ author = @engine.session&.last_turn&.participant_author
196
198
  author && @participant_refused.call(author)
197
199
  end
198
200
 
@@ -204,11 +206,11 @@ module Samagotchi
204
206
  # participant turns go on). The question goes with the next close.
205
207
  # Called with the event log held.
206
208
  def refuse_continue(reason, command)
207
- author = @engine.session&.last_turn&.author
209
+ author = @engine.session&.last_turn&.participant_author
208
210
  enqueued_id = offer_turn_id
209
211
  @engine.announce(type: :continue_resolved, decision: "refused", reason: reason.to_s, client_id: command.client_id)
210
212
  @engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
211
- text: format(REFUSED_NOTICE, who: author.to_s.delete_prefix("p:"), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
213
+ text: format(REFUSED_NOTICE, who: Steer.participant_id(author), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
212
214
  @turn_flow.abort_continue!
213
215
  session = @engine.session
214
216
  session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3), enqueued_id: enqueued_id) if session