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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +3 -1
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +6 -2
- data/docs/sessions.md +15 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/toolkit.rb +68 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +23 -9
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- 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 "
|
|
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 &&
|
|
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::
|
|
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) =
|
|
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
|
|
@@ -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
|
-
|
|
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)
|
|
59
|
-
#
|
|
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"
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
#
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
211
|
-
client
|
|
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
|
-
|
|
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
|
|
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 "../
|
|
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 = @
|
|
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 =
|
|
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.
|
|
66
|
-
#
|
|
67
|
-
#
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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(@
|
|
158
|
+
def session_dir = Session.session_dir(@session_id, state_dir: @state_dir)
|
|
150
159
|
|
|
151
160
|
def feature?(name)
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
link = WorkerLink.resolve(session_dir, unlink: false)
|
|
162
|
+
!link.live? || link.features.include?(name)
|
|
154
163
|
end
|
|
155
164
|
end
|
|
156
165
|
end
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|