samagotchi 0.121.0 → 0.121.1

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
@@ -10,7 +10,7 @@ require_relative "session_metrics"
10
10
  require_relative "recap_store"
11
11
  require_relative "session_delete_command"
12
12
  require_relative "session_archive_command"
13
- require_relative "bridge_client"
13
+ require_relative "worker_link"
14
14
  require_relative "terminal_ui/formatting"
15
15
  require_relative "terminal_ui/event_renderer"
16
16
  require_relative "cli/command"
@@ -298,7 +298,7 @@ module Samagotchi
298
298
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
299
299
  child += " ↪ #{s.continues[0, 8]}" if s.continues
300
300
  # A question waits for an answer (chi answer, the web, chi --attach).
301
- live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
301
+ live = s.pending_question && worker_owned?(s.id, state_dir)
302
302
  waiting = s.waiting_question(live: !!live)
303
303
  status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
304
304
  elsif waiting then "waiting"
@@ -371,7 +371,7 @@ module Samagotchi
371
371
  # @return [Array(Hash, nil, Boolean, String, nil)]
372
372
  def stats_snapshot_for(session)
373
373
  dir = Samagotchi::Session.session_dir(session.id)
374
- if (client = Samagotchi::BridgeClient.discover(session.id, session_dir: dir, read_timeout: STATS_TIMEOUT))
374
+ if (client = Samagotchi::WorkerLink.client(session.id, session_dir: dir, read_timeout: STATS_TIMEOUT))
375
375
  body = client.get_json("stats")&.dig("metrics")
376
376
  if body.is_a?(Hash)
377
377
  return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
@@ -384,7 +384,12 @@ module Samagotchi
384
384
  [snapshot, false, note]
385
385
  end
386
386
 
387
- def live_worker?(session_id) = Samagotchi::SessionManager.worker_live?(session_id)
387
+ def live_worker?(session_id) = worker_owned?(session_id, nil)
388
+
389
+ def worker_owned?(session_id, state_dir)
390
+ dir = Samagotchi::Session.session_dir(session_id, state_dir: state_dir || Samagotchi::Session.default_state_dir)
391
+ Samagotchi::WorkerLink.resolve(dir, unlink: false).worker_owned?
392
+ end
388
393
 
389
394
  def stop(ids)
390
395
  return usage_error("give session ids") if ids.empty?
@@ -432,7 +437,7 @@ module Samagotchi
432
437
  next false
433
438
  rescue Samagotchi::SessionManager::RestartRefused => e
434
439
  @stdout.flush
435
- @stderr.puts %i[not_running unsupported].include?(e.reason) ? e.message : "session #{id}: #{e.message}"
440
+ @stderr.puts %i[not_running unsupported fallback_held failed].include?(e.reason) ? e.message : "session #{id}: #{e.message}"
436
441
  next false
437
442
  rescue ArgumentError => e
438
443
  @stdout.flush
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../config"
4
4
  require_relative "../log"
5
+ require_relative "../llm/errors"
5
6
  require_relative "answer"
6
7
  require_relative "errors"
7
8
  require_relative "http_backend"
@@ -12,7 +13,9 @@ module Samagotchi
12
13
  # The one way anything asks System 1 a question: #ask answers a set of
13
14
  # questions about a state, or says nothing. A backend that is unreachable,
14
15
  # slow, refusing or nonsensical is a nil — no judgment, one log line — and
15
- # the caller asks the user as it would have anyway.
16
+ # the caller asks the user as it would have anyway. A turn cancelled
17
+ # while the judge is asked is not a failed judgment: its
18
+ # LLM::RequestCancelled goes on to the caller, which asks no one.
16
19
  #
17
20
  # client = SystemOne::Client.from_config(host_registry: registry)
18
21
  # client&.ask(state: "git push origin main", questions: {"allow" => question})
@@ -61,6 +64,8 @@ module Samagotchi
61
64
 
62
65
  answers = begin
63
66
  @backend.ask(state: state, questions: questions, cancel_controller: cancel_controller)
67
+ rescue LLM::RequestCancelled
68
+ raise
64
69
  rescue StandardError => e
65
70
  return no_judgment("#{e.class}: #{e.message}", error: e.class.name)
66
71
  end
@@ -37,8 +37,6 @@ module Samagotchi
37
37
  (@answers || {}).select { |id, _answer| ids.include?(id) }
38
38
  end
39
39
 
40
- def failing? = @failing
41
-
42
40
  def inspect = "#<#{self.class.name} calls=#{@calls}#{" failing" if @failing}>"
43
41
  end
44
42
  end
@@ -47,20 +47,27 @@ module Samagotchi
47
47
 
48
48
  # @param id [String] a judgment's id
49
49
  # @param answer [String] one of ANSWERS
50
+ # @param question_id [String, nil] the question the user answered: a
51
+ # live judgment is written before its card exists, so its label
52
+ # carries the card's id (what `chi guardrails replay` skips by)
50
53
  # @return [Boolean] whether the line was written
51
- def label(id, answer)
54
+ def label(id, answer, question_id: nil)
52
55
  return false if id.to_s.strip.empty?
53
56
 
54
- append({ "kind" => "label", "id" => id.to_s, "at" => Time.now.utc.iso8601, "answer" => answer.to_s })
57
+ line = { "kind" => "label", "id" => id.to_s, "at" => Time.now.utc.iso8601, "answer" => answer.to_s }
58
+ line["question_id"] = question_id.to_s unless question_id.to_s.empty?
59
+ append(line)
55
60
  end
56
61
 
57
62
  # The judgments, oldest first, each joined with its LAST label
58
- # (answer: nil when it has none). Lines that don't parse are skipped;
59
- # labels of a judgment that isn't there are dropped.
63
+ # (answer: nil when it has none) and the first question_id a label
64
+ # gave it, when the judgment line has none. Lines that don't parse are
65
+ # skipped; labels of a judgment that isn't there are dropped.
60
66
  # @return [Array<Hash>]
61
67
  def entries
62
68
  judgments = {}
63
69
  labels = {}
70
+ question_ids = {}
64
71
  with_lock(File::LOCK_SH) do |file|
65
72
  file.each_line do |line|
66
73
  data = parse(line)
@@ -68,11 +75,19 @@ module Samagotchi
68
75
 
69
76
  case data["kind"]
70
77
  when "judgment" then judgments[data["id"]] = data
71
- when "label" then labels[data["id"]] = data["answer"] if data.key?("id")
78
+ when "label"
79
+ next unless data.key?("id")
80
+
81
+ labels[data["id"]] = data["answer"]
82
+ question_ids[data["id"]] ||= data["question_id"]
72
83
  end
73
84
  end
74
85
  end
75
- judgments.map { |id, judgment| judgment.merge("answer" => labels[id]) }
86
+ judgments.map do |id, judgment|
87
+ joined = judgment.merge("answer" => labels[id])
88
+ joined["question_id"] ||= question_ids[id] if question_ids[id]
89
+ joined
90
+ end
76
91
  rescue StandardError => e
77
92
  warn_about(e)
78
93
  []
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../paths"
4
3
  require_relative "../config"
5
4
  require_relative "../memory_paths"
6
- require_relative "../hooks/loader"
7
5
  require_relative "../guardrails/protected_paths"
6
+ require_relative "../guardrails/parent_approvals"
8
7
 
9
8
  module Samagotchi
10
9
  module SystemOne
@@ -23,13 +22,17 @@ module Samagotchi
23
22
  # there is one agent approving another's, so the user decides them even
24
23
  # when they are listed in `guardrails.system_one.rules`.
25
24
  NEVER_AUTO = %w[chi-answer-piped chi-answer-http chi-broadcast].freeze
26
- # chi's config file, however the user names it in config.yml.
27
- CONFIG_FILE = /\Aconfig[\w-]*\.ya?ml\z/i
28
- # The dirs as a shell command may spell them when the settings point
29
- # elsewhere: chi's default config dir and default state dir. A path
30
- # into the memories dir is not a control file.
31
- DEFAULT_CONFIG_TEXT = %r{\B~/\.config/samagotchi(?!/memories)}
25
+ # chi's default state dir as a shell command may spell it when the
26
+ # settings point elsewhere (the sessions and the guardrails store live
27
+ # there).
32
28
  DEFAULT_STATE_TEXT = %r{\B~/\.local/state/samagotchi}
29
+ # The characters that end a shell word: a memories path in a command
30
+ # ends at the first one.
31
+ WORD_END = %q{[\s'"`;&|<>(){}:,]}
32
+ # A `..` segment anywhere in a command: with one, a memories path may
33
+ # climb back out (`memories/../config.yml`, `cd memories && … ../x`),
34
+ # so the command's memories text is not taken out.
35
+ CLIMB = %r{(?:\A|[\s/'"=])\.\.(?:/|[\s'"]|\z)}
33
36
 
34
37
  module_function
35
38
 
@@ -39,47 +42,40 @@ module Samagotchi
39
42
  def refuse?(approval)
40
43
  return false unless approval.is_a?(Hash)
41
44
  return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
42
-
43
- dirs = control_dirs
44
- paths = Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?)
45
- return true if paths.any? { |path| control_path?(path, dirs) }
45
+ # The test that makes an ask user_only (ParentApprovals#chi_files?:
46
+ # chi's dirs, however a command spells them), minus the memories.
47
+ return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval))
46
48
 
47
49
  command = fetch(approval, :command).to_s
48
- !command.empty? && control_text?(command, dirs)
49
- end
50
-
51
- # The dirs whose files are chi's own: its hooks, its installed bundles,
52
- # the guardrails store (approvals.json and judgments.jsonl).
53
- # @return [Array<String>]
54
- def control_dirs
55
- [Hooks::Loader.hooks_dir, MemoryPaths.bundles_dir, File.join(Paths.state_dir, "guardrails")]
56
- .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?)
50
+ command.match?(DEFAULT_STATE_TEXT)
57
51
  end
58
52
 
59
- def control_path?(path, dirs)
60
- real = Guardrails::ProtectedPaths.real(path)
61
- return true if dirs.any? { |dir| Guardrails::ProtectedPaths.within?(real, dir) }
62
- return true if config_file?(real)
63
-
64
- control_text?(path, dirs)
65
- end
66
-
67
- # config.yml in chi's config dir — not the memories beside it.
68
- def config_file?(path)
69
- Guardrails::ProtectedPaths.within?(path, ConfigFile.config_dir) && File.basename(path).match?(CONFIG_FILE)
70
- end
71
-
72
- # A command or path naming one of those dirs, in the spelling this
73
- # process uses or the default `~/.config/samagotchi` one.
74
- def control_text?(text, dirs)
75
- dirs.any? { |dir| text.include?(dir) || text.include?(File.expand_path(dir)) } ||
76
- text.match?(config_text) || text.match?(DEFAULT_CONFIG_TEXT) || text.match?(DEFAULT_STATE_TEXT)
53
+ # The approval's facts without what is in the memories dir (not its
54
+ # installed bundles): the paths under it, and its spellings in the
55
+ # command unless the command climbs with `..`.
56
+ def beyond_memories(approval)
57
+ memories = Guardrails::ProtectedPaths.real(MemoryPaths.system_dir)
58
+ bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
59
+ paths = Array(fetch(approval, :paths)).map(&:to_s).reject do |path|
60
+ real = Guardrails::ProtectedPaths.real(path)
61
+ Guardrails::ProtectedPaths.within?(real, memories) && !Guardrails::ProtectedPaths.within?(real, bundles)
62
+ end
63
+ command = fetch(approval, :command).to_s
64
+ unless command.match?(CLIMB)
65
+ command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" }
66
+ end
67
+ { paths: paths, command: command }
77
68
  end
78
69
 
79
- # chi's config dir as text — everything under it except the memories,
80
- # which the model works with.
81
- def config_text
82
- Regexp.new("#{Regexp.escape(ConfigFile.config_dir)}(?![/]memories)")
70
+ # The memories dir as a command spells it: the default
71
+ # `.config/samagotchi/memories`, or this process's own path, with the
72
+ # rest of that shell word.
73
+ def memories_text
74
+ dir = ConfigFile.config_dir
75
+ prefixes = [".config/samagotchi", dir, Guardrails::ProtectedPaths.real(dir)]
76
+ .map { |prefix| Regexp.escape(prefix.to_s.chomp("/")) }.uniq
77
+ word = WORD_END.sub("[", "[^")
78
+ Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
83
79
  end
84
80
 
85
81
  def fetch(hash, key)
@@ -6,6 +6,7 @@ require_relative "../model_profile"
6
6
  require_relative "../session_manager"
7
7
  require_relative "../thinking_command"
8
8
  require_relative "../bridge_client"
9
+ require_relative "../worker_link"
9
10
  require_relative "../parent_report"
10
11
  require_relative "../cli/exit"
11
12
  require_relative "../launch_mode"
@@ -207,8 +208,8 @@ module Samagotchi
207
208
  toolkit: toolkit, settings: settings,
208
209
  parent_answers: parent_answers))
209
210
  end
210
- client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
211
- client || raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
211
+ link = WorkerLink.await(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
212
+ link.live? ? link.client(session.id) : raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
212
213
  rescue SessionManager::OwnedByTUI => e
213
214
  raise Error, e.message
214
215
  rescue ArgumentError => e
@@ -248,9 +249,13 @@ module Samagotchi
248
249
  end
249
250
 
250
251
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
252
+ # @raise [Error] its worker is newer than this chi (Skew)
251
253
  def connect_existing(session_id, state_dir)
252
254
  Session.load(session_id, state_dir: state_dir)
253
- BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
255
+ link = WorkerLink.resolve(Session.session_dir(session_id, state_dir: state_dir), unlink: true)
256
+ raise Error, link.message(session_id) if link.is_a?(WorkerLink::Skew)
257
+
258
+ link.live? ? link.client(session_id) : nil
254
259
  end
255
260
  end
256
261
  end
@@ -439,7 +439,7 @@ module Samagotchi
439
439
 
440
440
  # The stream moved to a new worker (a restart): requests go there too.
441
441
  def switch_worker(event)
442
- @client = @link.switched(event)
442
+ @client = @link.switched(event) || @client
443
443
  @worker_changed = true
444
444
  # The new worker's machine may name another default model.
445
445
  @default_model_name = nil
@@ -452,9 +452,11 @@ module Samagotchi
452
452
  def closed_line(reason)
453
453
  id = @client.session_id
454
454
  # The worker asked for a secret this terminal's chi doesn't send.
455
- return "Lost the session's worker: #{BridgeSecret::REFUSED}" if reason == BridgeSecret::ERROR
455
+ return "Lost the session's worker: #{BridgeSecret.refused(id, via: via)}" if reason == BridgeSecret::ERROR
456
456
  return remote_closed_line(reason, id) if via.remote?
457
457
 
458
+ return "Lost the session's worker: #{@link.failure}" if reason == "worker_newer" && @link.failure
459
+
458
460
  # Here a stopped session has always read as unreachable.
459
461
  reason = "unreachable" if reason == "stopped"
460
462
  "Lost the session's worker (#{reason}). Resume it with: #{via.command("--shared --resume #{id}")}"
@@ -3,7 +3,7 @@
3
3
  require_relative "attach_link"
4
4
  require_relative "../session"
5
5
  require_relative "../bridge_client"
6
- require_relative "../worker_sidecar"
6
+ require_relative "../worker_link"
7
7
  require_relative "../delivery"
8
8
  require_relative "../session_inbox"
9
9
  require_relative "../image_store"
@@ -42,6 +42,8 @@ module Samagotchi
42
42
  installed_version: -> { InstalledVersions.new.newest },
43
43
  children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
44
44
  @client = client
45
+ @session_id = client.session_id
46
+ @host = BridgeClient::HOST
45
47
  @state_dir = state_dir
46
48
  @delete_session = delete_session
47
49
  @archive_session = archive_session
@@ -52,30 +54,36 @@ module Samagotchi
52
54
  # The sidecar's worker, nil while none runs; gone once the session was
53
55
  # stopped (no worker is coming): its stop marker in +state_dir+.
54
56
  def live_worker
55
- id = @client.session_id
57
+ id = @session_id
56
58
  return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
57
59
 
60
+ link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
61
+ if link.is_a?(WorkerLink::Skew)
62
+ @failure = link.message(id)
63
+ return [:gone, "worker_newer"]
64
+ end
65
+
58
66
  # Kept: the stream moves to this very client (EventStream), and
59
67
  # #switched takes it, port and secret, rather than reading again.
60
- @found = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
68
+ @found = link.live? ? link.client(id, host: @host) : nil
61
69
  [@found, nil]
62
70
  end
63
71
 
64
72
  # A new worker's Bridge: the client #live_worker found for the stream
65
- # (the event names its port), with that worker's secret. One the event
66
- # doesn't name (another worker since) is read from the sidecar for that
67
- # port, none when the sidecar names another one by now: that worker is
68
- # gone too, and the stream finds the next.
73
+ # (the event names its port), with that worker's secret. An event that
74
+ # names another worker is resolved again; a mismatched sidecar never
75
+ # becomes a secretless client.
69
76
  def switched(event)
70
77
  port = AttachLink.event_port(event)
71
78
  found = @found
72
- @client = if found&.port == port
73
- found
74
- else
75
- sidecar = WorkerSidecar.read(session_dir)
76
- sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
77
- BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
78
- end
79
+ candidate = if found&.port == port
80
+ found
81
+ else
82
+ link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
83
+ link.live? && link.port == port ? link.client(@session_id, host: @host) : nil
84
+ end
85
+ @client = candidate if candidate
86
+ candidate
79
87
  end
80
88
 
81
89
  # No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
@@ -139,18 +147,19 @@ module Samagotchi
139
147
 
140
148
  def via = ChiInvocation::HERE
141
149
 
142
- # The closing line says it all (unreachable, unknown_session).
143
- def failure = nil
150
+ # The closing line says it all (unreachable, unknown_session); a worker
151
+ # newer than this chi names itself.
152
+ attr_reader :failure
144
153
 
145
154
  def close = nil
146
155
 
147
156
  private
148
157
 
149
- def session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
158
+ def session_dir = Session.session_dir(@session_id, state_dir: @state_dir)
150
159
 
151
160
  def feature?(name)
152
- sidecar = WorkerSidecar.read(session_dir)
153
- sidecar.nil? || sidecar.features.include?(name)
161
+ link = WorkerLink.resolve(session_dir, unlink: false)
162
+ !link.live? || link.features.include?(name)
154
163
  end
155
164
  end
156
165
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "model_match"
4
5
 
5
6
  module Samagotchi
6
7
  # A session's toolkit: which of the registered tools its prompts declare
@@ -118,8 +119,66 @@ module Samagotchi
118
119
  Array(groups).map { |group| group.to_s.strip }.reject(&:empty?).uniq.sort.freeze
119
120
  end
120
121
 
122
+ # Whether a source group +name+ is one of +selectors+: fnmatch globs in
123
+ # the guardrails rules' grammar (ModelMatch::GLOB_FLAGS: any case,
124
+ # extglob), so `groups: [Chrome]` reaches chrome.
121
125
  def self.group_matches?(selectors, name)
122
- selectors == :all || selectors.any? { |selector| File.fnmatch?(selector, name.to_s) }
126
+ selectors == :all || selectors.any? { |selector| File.fnmatch?(selector, name.to_s, ModelMatch::GLOB_FLAGS) }
127
+ end
128
+
129
+ # Why +value+ can't be a kit's groups (a group name or glob, or a list
130
+ # of them), nil when it can (nil too: its base's groups).
131
+ def self.groups_error(value)
132
+ return nil if value.nil? || value.is_a?(String)
133
+ return nil if value.is_a?(Array) && value.all? { |group| group.is_a?(String) || group.is_a?(Symbol) || group.is_a?(Numeric) }
134
+
135
+ "groups must be a list of group names or globs"
136
+ end
137
+
138
+ # config.yml's toolkit.kits.NAME line for a +value+ that can't be its
139
+ # groups (groups_error), nil when it can: the one wording a worker's
140
+ # warning and a creation's refusal both start with.
141
+ def self.config_groups_error(name, value)
142
+ problem = groups_error(value)
143
+ problem && "toolkit.kits.#{name}: #{problem}"
144
+ end
145
+
146
+ # A new session refused: its toolkit, or a base it builds on, is a
147
+ # config.yml kit (toolkit.kits) whose groups: isn't a name or a list of
148
+ # them. A session never starts on a reach other than the one written
149
+ # (its base's groups: wider or narrower); the message is the one line
150
+ # every creation surface shows.
151
+ class Refused < StandardError
152
+ # @param toolkit [String] the new session's toolkit name
153
+ # @param problem [String] config_groups_error's line
154
+ def self.for(toolkit, problem) = new("#{problem}; fix config.yml to start a session on toolkit #{toolkit}")
155
+ end
156
+
157
+ # Raises Refused when +toolkit+ (a new session's: Session.new_session)
158
+ # names a config kit, or builds on one through its bases, whose
159
+ # groups: is invalid (config_groups_error). Only config.yml's kits are
160
+ # followed: a bundle's (chi.toolkit) refuses at its load, and a name
161
+ # that doesn't resolve stays the worker's warning.
162
+ # @param config [Hash, nil] toolkit.kits (config.yml's by default)
163
+ # @return [Toolkit] +toolkit+
164
+ # @raise [Refused]
165
+ def self.check_new!(toolkit, config: config_kits)
166
+ return toolkit unless toolkit && config.is_a?(Hash)
167
+
168
+ name = toolkit.name
169
+ seen = []
170
+ until seen.include?(name) || name == REVIEW || BUILTIN_KITS.key?(name)
171
+ seen << name
172
+ spec = config[name] || config[name.to_sym]
173
+ break unless spec.is_a?(Hash)
174
+
175
+ spec = spec.transform_keys(&:to_s)
176
+ problem = config_groups_error(name, spec["groups"])
177
+ raise Refused.for(toolkit.name, problem) if problem
178
+
179
+ name = (spec["base"] || FULL).to_s
180
+ end
181
+ toolkit
123
182
  end
124
183
 
125
184
  # A named list of declared tools: +tools+ (:all, every built-in; or the
@@ -304,7 +363,10 @@ module Samagotchi
304
363
  # that isn't a {base:, add:, remove:, prompt:, groups:} map. One with another key
305
364
  # (a typo, a tools: list) still loads, with a line naming the key, and
306
365
  # so does one whose prompt isn't text or is over PROMPT_MAX characters:
307
- # without the prompt, with a line saying why.
366
+ # without the prompt, with a line saying why. One whose groups: isn't
367
+ # a name or a list (config_groups_error) loads reaching no group, with
368
+ # a line: a session saved on it before the typo (no new one starts on
369
+ # it: check_new!) never reaches wider than written.
308
370
  # @param config [Hash, nil] toolkit.kits
309
371
  # @param plugins [Array<Kit>]
310
372
  # @param errors [Array<String>] gets a line per kit left out or unknown key
@@ -329,6 +391,10 @@ module Samagotchi
329
391
  errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
330
392
  spec.delete("prompt")
331
393
  end
394
+ if (problem = config_groups_error(name, spec["groups"]))
395
+ errors << "#{problem}; the kit reaches no tool-index group until it is fixed"
396
+ spec["groups"] = []
397
+ end
332
398
  kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
333
399
  prompt: spec["prompt"], groups: spec["groups"], source: "config")
334
400
  end
@@ -81,7 +81,7 @@ module Samagotchi
81
81
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
82
82
  "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
83
83
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
84
- rescue ArgumentError => e
84
+ rescue ArgumentError, Toolkit::Refused => e
85
85
  "Error: #{e.message}"
86
86
  end
87
87
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../bridge_client"
5
+ require_relative "../worker_link"
5
6
  require_relative "../relay"
6
7
  require_relative "../guardrails/approval"
7
8
  require_relative "../guardrails/parent_policy"
@@ -79,13 +80,14 @@ module Samagotchi
79
80
  qid = question.id.to_s
80
81
  desk = relay.relay_desk
81
82
  relay_id = desk.open(child_id: child_id, child_question_id: qid)
82
- client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
83
+ client = WorkerLink.client(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
83
84
  post(client, "opened", relay_id, qid)
84
85
  Log.info(:turn, "relay_opened", child: child_id[0, 8], id: qid)
85
86
 
86
87
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
87
88
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
88
- settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid)
89
+ settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid,
90
+ child_id: child_id)
89
91
  ensure
90
92
  # A relay that failed before it settled closes, so the desk (which
91
93
  # prunes only settled ones) keeps none open; a settled one stays.
@@ -96,7 +98,7 @@ module Samagotchi
96
98
  # was answered or cancelled there) or its worker is gone.
97
99
  def watch(child_id, qid, state_dir)
98
100
  lambda do
99
- unless SessionManager.worker_live?(child_id, state_dir: state_dir)
101
+ unless WorkerLink.resolve(Session.session_dir(child_id, state_dir: state_dir), unlink: false).worker_owned?
100
102
  next "child_gone"
101
103
  end
102
104
 
@@ -109,7 +111,7 @@ module Samagotchi
109
111
 
110
112
  # Record the parent's answer, tell the child, and say what came of it.
111
113
  # @param label [String] the outcome line's start (#label)
112
- def settle(answer, fields, label:, client:, desk:, relay_id:, qid:)
114
+ def settle(answer, fields, label:, client:, desk:, relay_id:, qid:, child_id:)
113
115
  if answer.is_a?(Hash) && answer[:error] == "cancelled"
114
116
  desk.close(relay_id)
115
117
  return closed(answer[:reason].to_s, label, client: client, relay_id: relay_id, qid: qid)
@@ -120,7 +122,7 @@ module Samagotchi
120
122
  desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
121
123
  freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
122
124
  response = post(client, "answered", relay_id, qid)
123
- Outcome.new(line: line(label, delivered(response, answer, fields, dismissed)), stopped: false)
125
+ Outcome.new(line: line(label, delivered(response, answer, fields, dismissed, child_id)), stopped: false)
124
126
  end
125
127
 
126
128
  def closed(reason, label, client:, relay_id:, qid:)
@@ -136,12 +138,12 @@ module Samagotchi
136
138
  end
137
139
 
138
140
  # The outcome of an answer the child was told about.
139
- def delivered(response, answer, fields, dismissed)
141
+ def delivered(response, answer, fields, dismissed, child_id)
140
142
  return "not delivered (the child's worker is gone)" unless response
141
143
 
142
144
  # The child's Bridge asked for a secret this worker didn't send (one
143
145
  # from before the child's chi): not the child's guardrails refusing.
144
- return "not delivered (#{BridgeSecret::REFUSED})" if response.secret_refused?
146
+ return "not delivered (#{BridgeSecret.refused(child_id)})" if response.secret_refused?
145
147
 
146
148
  hook = fields[:kind] == HOOK_KIND
147
149
  case response.status