samagotchi 0.31.0 → 0.33.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. metadata +18 -1
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "context_sources"
5
+ require_relative "context_providers"
6
+ require_relative "log"
7
+ require_relative "process_group"
8
+
9
+ module Samagotchi
10
+ # Runs an attached context source's command once and records the result
11
+ # in its snapshot (ContextSources): the worker's ContextPoller and
12
+ # `chi context refresh` both come here.
13
+ #
14
+ # The command runs with `sh -c` in its own process group (a timeout, the
15
+ # output cap or the caller's cancel kills the group: no orphans), in
16
+ # +cwd+, with stdin closed, SAMAGOTCHI_CONTEXT_NAME and (once there is
17
+ # one) SAMAGOTCHI_CONTEXT_PREVIOUS, the last snapshot's path. stdout is
18
+ # the text (ContextSources.parse_output); stderr goes to the debug log,
19
+ # its last line into the error. A non-blocking flock on <name>.lock keeps
20
+ # two workers (or a worker and the CLI) from running one source at once.
21
+ module ContextFetch
22
+ TIMEOUT_SECONDS = 60
23
+ STDERR_TAIL_BYTES = 4096
24
+ READ_CHUNK = 64 * 1024
25
+ # How long a group gets after TERM before KILL.
26
+ KILL_GRACE_SECONDS = 1.0
27
+ OVER_CAP = "it printed more than 1 MiB"
28
+
29
+ # +status+: :new (a new revision), :same (the same text), :error (the
30
+ # fetch failed: +error+), :busy (another process holds the lock),
31
+ # :fresh (tried within +fresh_within+, by someone else meanwhile),
32
+ # :cancelled (the caller stopped it: nothing recorded, since a worker
33
+ # leaving isn't the source failing), :gone (the source was removed or
34
+ # replaced while it ran: nothing recorded for it).
35
+ Outcome = Data.define(:status, :snapshot, :error)
36
+ # What the command did: +output+ its stdout, +error+ nil or why it
37
+ # failed, +cancelled+ the caller stopped it.
38
+ Run = Data.define(:output, :error, :stderr, :cancelled)
39
+
40
+ module_function
41
+
42
+ # @param attached [ContextSources::Attached] a command source
43
+ # @param fresh_within [Numeric, nil] skip it when it was tried this
44
+ # many seconds ago (checked under the lock)
45
+ # @param cancelled [#call] true: kill the command (the worker stops)
46
+ # @return [Outcome]
47
+ def fetch(attached, cwd:, fresh_within: nil, timeout: TIMEOUT_SECONDS, cancelled: -> { false })
48
+ location = attached.location
49
+ name = attached.name
50
+ File.open(location.lock_path(name), File::RDWR | File::CREAT, 0o644) do |lock|
51
+ return Outcome.new(status: :busy, snapshot: nil, error: nil) unless lock.flock(File::LOCK_EX | File::LOCK_NB)
52
+
53
+ before = location.snapshot(name)
54
+ age = before.age
55
+ return Outcome.new(status: :fresh, snapshot: before, error: nil) if fresh_within && age && age < fresh_within
56
+
57
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ run = begin
59
+ run_command(ContextProviders.command_for(attached.source), cwd: cwd, env: env_for(location, name),
60
+ timeout: timeout, cancelled: cancelled)
61
+ rescue ContextProviders::Invalid => e
62
+ Run.new(output: nil, error: e.message, stderr: "", cancelled: false)
63
+ end
64
+ outcome = if run.cancelled
65
+ Outcome.new(status: :cancelled, snapshot: before, error: nil)
66
+ elsif !current?(attached)
67
+ Outcome.new(status: :gone, snapshot: nil, error: nil)
68
+ else
69
+ record_current(attached, run, before)
70
+ end
71
+ Log.info(:context, "fetched", name: name, scope: location.scope, status: outcome.status,
72
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
73
+ error: outcome.error)
74
+ outcome
75
+ end
76
+ end
77
+
78
+ # Whether the source is still the one this fetch ran: `chi context rm`
79
+ # (or rm and add with another command) doesn't take the lock.
80
+ def current?(attached) = attached.location.source(attached.name) == attached.source
81
+
82
+ # #record, then the check again: a removal between the first check and
83
+ # the write leaves no snapshot behind (the lock is still ours, so the
84
+ # file is ours too).
85
+ def record_current(attached, run, before)
86
+ outcome = record(attached.location, attached.name, run, before)
87
+ return outcome if current?(attached)
88
+
89
+ FileUtils.rm_f(attached.location.snapshot_path(attached.name))
90
+ Outcome.new(status: :gone, snapshot: nil, error: nil)
91
+ end
92
+
93
+ def record(location, name, run, before)
94
+ if run.error
95
+ snapshot = location.record_error(name, run.error)
96
+ return Outcome.new(status: :error, snapshot: snapshot, error: snapshot.error)
97
+ end
98
+
99
+ begin
100
+ fetched = ContextSources.parse_output(run.output)
101
+ rescue ContextSources::Invalid => e
102
+ snapshot = location.record_error(name, e.message)
103
+ return Outcome.new(status: :error, snapshot: snapshot, error: snapshot.error)
104
+ end
105
+ snapshot = location.record_text(name, fetched)
106
+ Outcome.new(status: snapshot.revision == before.revision ? :same : :new, snapshot: snapshot, error: nil)
107
+ end
108
+
109
+ def env_for(location, name)
110
+ previous = location.snapshot_path(name)
111
+ { "SAMAGOTCHI_CONTEXT_NAME" => name,
112
+ "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil }
113
+ end
114
+
115
+ # @return [Run]
116
+ def run_command(cmd, cwd:, env:, timeout:, cancelled:)
117
+ return Run.new(output: nil, error: "its folder #{cwd} is gone", stderr: "", cancelled: false) unless cwd && Dir.exist?(cwd)
118
+
119
+ out_r, out_w = IO.pipe
120
+ err_r, err_w = IO.pipe
121
+ pid = ProcessGroup.spawn(env, "sh", "-c", cmd, chdir: cwd, in: File::NULL, out: out_w, err: err_w)
122
+ out_w.close
123
+ err_w.close
124
+ out_reader = ProcessGroup::PipeReader.new(out_r, cap: ContextSources::TEXT_MAX_BYTES, chunk: READ_CHUNK)
125
+ err_reader = ProcessGroup::PipeReader.new(err_r, cap: STDERR_TAIL_BYTES, keep: :tail, chunk: READ_CHUNK)
126
+
127
+ error, status = wait(pid, timeout: timeout, over: -> { out_reader.over? }, cancelled: cancelled)
128
+ reaped = true
129
+ # Whatever the command left running in its group goes with it.
130
+ ProcessGroup.signal(pid, "KILL")
131
+ [out_reader, err_reader].each { |reader| reader.wait(KILL_GRACE_SECONDS) || reader.kill }
132
+ stderr_text = err_reader.text(Encoding::UTF_8).scrub
133
+ Log.debug(:context, "stderr", text: stderr_text[-1000..] || stderr_text) unless stderr_text.strip.empty?
134
+ return Run.new(output: nil, error: nil, stderr: stderr_text, cancelled: true) if error == :cancelled
135
+
136
+ # Checked again once the reader is done: a command that exits before
137
+ # wait's next tick never gets an over from it.
138
+ error = OVER_CAP if out_reader.over?
139
+ error ||= exit_error(status, stderr_text)
140
+ Run.new(output: out_reader.text, error: error, stderr: stderr_text, cancelled: false)
141
+ rescue SystemCallError => e
142
+ Run.new(output: nil, error: "couldn't run it: #{e.message}", stderr: "", cancelled: false)
143
+ ensure
144
+ # Cut short (Ctrl-C in chi context refresh, an exception, a killed
145
+ # poller thread): the group doesn't get the terminal's SIGINT
146
+ # (pgroup), so stop and reap it here.
147
+ abandon(pid, [out_reader, err_reader]) if pid && !reaped
148
+ [out_r, out_w, err_r, err_w].each { |io| io&.close unless io&.closed? }
149
+ end
150
+
151
+ # @return [Array(String|Symbol|nil, Process::Status|nil)] why it was
152
+ # stopped (:cancelled for the caller), and its status
153
+ def wait(pid, timeout:, over:, cancelled:)
154
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
155
+ loop do
156
+ _, status = Process.wait2(pid, Process::WNOHANG)
157
+ return [nil, status] if status
158
+
159
+ reason = if over.call then OVER_CAP
160
+ elsif cancelled.call then :cancelled
161
+ elsif Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline then "timed out after #{timeout.round} s"
162
+ end
163
+ return [reason, stop_group(pid)] if reason
164
+
165
+ sleep(0.05)
166
+ end
167
+ end
168
+
169
+ def abandon(pid, readers)
170
+ stop_group(pid)
171
+ readers.each { |reader| reader&.kill }
172
+ rescue StandardError => e
173
+ Log.warn(:context, "stop_failed", pid: pid, error: e.class.name)
174
+ end
175
+
176
+ # TERM, then KILL after a grace; @return [Process::Status, nil]
177
+ def stop_group(pid)
178
+ status = nil
179
+ reaped = -> { status = Process.wait2(pid, Process::WNOHANG)&.last }
180
+ return status if ProcessGroup.stop(pid, grace: KILL_GRACE_SECONDS, poll: 0.05, stopped: reaped)
181
+
182
+ Process.wait2(pid).last
183
+ rescue Errno::ECHILD
184
+ nil
185
+ end
186
+
187
+ def exit_error(status, stderr)
188
+ return nil if status.nil? || status.success?
189
+
190
+ last = stderr.lines.map(&:strip).reject(&:empty?).last
191
+ code = status.exitstatus ? "exit #{status.exitstatus}" : "killed by signal #{status.termsig}"
192
+ last ? "#{code}: #{last}" : code
193
+ end
194
+ end
195
+ end
@@ -11,16 +11,22 @@ module Samagotchi
11
11
  KIND = "note"
12
12
  # The message keys a note carries besides role and content; everything
13
13
  # that copies messages field by field must keep them.
14
- KEYS = %i[kind note_id source from_session from_cwd].freeze
14
+ # context_source: the attached context source a note is about
15
+ # (ContextAbsorber), so the UIs can tie it to its chip.
16
+ KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
15
17
 
16
18
  module_function
17
19
 
18
- # Takes SessionInbox.read_note's keys.
20
+ # Takes SessionInbox.read_note's keys. +turn_start+ / +turn_id+: the
21
+ # note starts a turn chi runs for it (a context wake), as a wake turn's
22
+ # delegate report does (Steer.turn_prompt?).
19
23
  # @return [Hash] the conversation message
20
- def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, **)
24
+ def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
25
+ turn_start: nil, turn_id: nil, **)
21
26
  { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
22
- from_cwd: from_cwd, content: frame(text, source: source, created_at: created_at,
23
- from_session: from_session, from_cwd: from_cwd) }.compact
27
+ from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
28
+ content: frame(text, source: source, created_at: created_at, from_session: from_session,
29
+ from_cwd: from_cwd) }.compact
24
30
  end
25
31
 
26
32
  def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "context_fetch"
5
+ require_relative "context_sources"
6
+ require_relative "log"
7
+
8
+ module Samagotchi
9
+ # A session worker's thread that keeps its attached context fresh: every
10
+ # TICK_SECONDS it runs each command source (the session's and its
11
+ # project's, not muted here) whose last try is older than its interval
12
+ # (--every, else context.every_seconds), and calls +on_change+ after a
13
+ # new text or a failure so the loop absorbs it at once. The first pass,
14
+ # right after the worker starts, runs every source not tried in the last
15
+ # MIN_EVERY_SECONDS: a woken worker's first absorb says what changed while
16
+ # it was away.
17
+ #
18
+ # Polling isn't activity: it never holds the worker up (WorkerIdleExit),
19
+ # and it stops with it (#stop kills a running command's group). Project
20
+ # sources are shared by the project's workers; ContextFetch's lock and a
21
+ # second freshness check under it make one fetch per interval.
22
+ class ContextPoller
23
+ TICK_SECONDS = 5
24
+
25
+ # @param project_root [String, nil] Session#project_root
26
+ # @param cwd [String] the session's folder (a session source runs there)
27
+ # @param on_change [#call] after a fetch brought a new text or failed
28
+ def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, tick: TICK_SECONDS)
29
+ @session_id = session_id
30
+ @state_dir = state_dir
31
+ @project_root = project_root
32
+ @cwd = cwd
33
+ @on_change = on_change
34
+ @tick = tick
35
+ @own = ContextSources.session_location(session_id, state_dir: state_dir)
36
+ @queue = Thread::Queue.new
37
+ @stopping = false
38
+ end
39
+
40
+ def start
41
+ @thread ||= Thread.new do
42
+ Thread.current.name = "context-poller"
43
+ run
44
+ end
45
+ self
46
+ end
47
+
48
+ # Stops the loop and kills a command it is running; returns once it has.
49
+ def stop
50
+ @stopping = true
51
+ @queue << :stop
52
+ @thread&.join((ContextFetch::KILL_GRACE_SECONDS * 3) + @tick)
53
+ @thread = nil
54
+ end
55
+
56
+ # One pass (the thread's loop body; specs call it).
57
+ # @param first [Boolean] the worker's first pass
58
+ def poll(first: false)
59
+ ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir).each do |attached|
60
+ break if @stopping
61
+ next if attached.source.push? || @own.muted?(attached.name)
62
+
63
+ interval = first ? ContextSources::MIN_EVERY_SECONDS : interval_of(attached.source)
64
+ age = attached.snapshot.age
65
+ next if age && age < interval
66
+
67
+ outcome = ContextFetch.fetch(attached, cwd: cwd_for(attached), fresh_within: interval, cancelled: -> { @stopping })
68
+ @on_change.call if %i[new error].include?(outcome.status)
69
+ rescue StandardError => e
70
+ Log.exception(:context, "poll_failed", e, name: attached.name)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def run
77
+ first = true
78
+ until @stopping
79
+ poll(first: first)
80
+ first = false
81
+ @queue.pop(timeout: @tick)
82
+ end
83
+ rescue StandardError => e
84
+ Log.exception(:context, "poller_crashed", e)
85
+ end
86
+
87
+ def interval_of(source)
88
+ seconds = source.every_seconds || Config.get("context.every_seconds").to_i
89
+ [seconds, ContextSources::MIN_EVERY_SECONDS].max
90
+ end
91
+
92
+ # A session's source runs in the session's folder, a project's in the
93
+ # project root (the session's folder when the root is no checkout).
94
+ def cwd_for(attached)
95
+ return @cwd if attached.location.session?
96
+
97
+ ContextSources.project_cwd(@project_root, @cwd)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "shellwords"
5
+
6
+ module Samagotchi
7
+ autoload :ContextSources, File.expand_path("context_sources", __dir__)
8
+ autoload :Log, File.expand_path("log", __dir__)
9
+ module MemoryBundle
10
+ autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
11
+ end
12
+
13
+ # Declarative context providers (attached context, §3.9): a bundle's
14
+ # manifest maps a URL pattern to a source, so any process (the CLI, the
15
+ # web server, a worker) can attach a URL without loading plugins:
16
+ #
17
+ # context_providers:
18
+ # - match: '\Ahttps://github\.com/([^/]+)/([^/]+)/pull/(\d+)'
19
+ # name: 'pr-\3'
20
+ # cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
21
+ # why: 'GitHub PR'
22
+ # every_seconds: 300
23
+ #
24
+ # The installer records them in the bundle's provenance; .resolve reads
25
+ # them from there. +name+ takes the match's groups (\1…); +cmd+ takes
26
+ # {url}, the matched URL (shell-quoted), when the source is attached, and
27
+ # keeps {bundle_dir}, the installed bundle's folder, and {ruby}, the Ruby
28
+ # chi runs on, for ContextFetch to fill in when it runs: a bundle upgrade
29
+ # moves nothing, and a `ruby` on the PATH may be another one (macOS's 2.6,
30
+ # a launchd PATH).
31
+ module ContextProviders
32
+ # A provider as the manifest declares it.
33
+ Provider = Data.define(:match, :name, :cmd, :why, :every_seconds) do
34
+ def to_h = { "match" => match, "name" => name, "cmd" => cmd, "why" => why, "every_seconds" => every_seconds }.compact
35
+
36
+ def regexp = Regexp.new(match)
37
+ end
38
+
39
+ # What a URL resolved to: the provider's bundle, and the source's
40
+ # fields (+cmd+ still holding {bundle_dir}).
41
+ Resolved = Data.define(:bundle, :name, :cmd, :why, :hint, :every_seconds)
42
+
43
+ class Invalid < ArgumentError; end
44
+
45
+ BUNDLE_DIR = "{bundle_dir}"
46
+ RUBY = "{ruby}"
47
+ URL = "{url}"
48
+ MIN_EVERY_SECONDS = 30
49
+
50
+ module_function
51
+
52
+ # The manifest's context_providers: list.
53
+ # @return [Array<Provider>] [] when absent
54
+ # @raise [Invalid]
55
+ def parse_list(raw)
56
+ return [] if raw.nil?
57
+ raise Invalid, "context_providers: must be a list of mappings" unless raw.is_a?(Array)
58
+
59
+ raw.map { |item| parse(item) }
60
+ end
61
+
62
+ # @raise [Invalid]
63
+ def parse(item)
64
+ raise Invalid, "context_providers: each item must be a mapping, not #{item.inspect}" unless item.is_a?(Hash)
65
+
66
+ data = item.transform_keys(&:to_s)
67
+ %w[match name cmd].each do |key|
68
+ value = data[key]
69
+ raise Invalid, "context_providers: an item has no #{key}:" unless value.is_a?(String) && !value.strip.empty?
70
+ end
71
+ begin
72
+ Regexp.new(data["match"])
73
+ rescue RegexpError => e
74
+ raise Invalid, "context_providers: match #{data["match"].inspect} isn't a regular expression (#{e.message})"
75
+ end
76
+ Provider.new(match: data["match"], name: data["name"], cmd: data["cmd"], why: data["why"]&.to_s,
77
+ every_seconds: every(data["every_seconds"]))
78
+ end
79
+
80
+ # The source the installed bundles' providers make of +url+: the first
81
+ # bundle (by name) whose provider matches. A bundle whose providers
82
+ # don't parse is skipped (logged).
83
+ # @return [Resolved, nil] nil when none matches
84
+ # @raise [Invalid] the provider makes a name that isn't a source name
85
+ def resolve(url)
86
+ url = url.to_s.strip
87
+ MemoryBundle::Provenance.each_installed do |bundle, data|
88
+ next if data[:error] || !data[:context_providers].is_a?(Array)
89
+
90
+ providers(bundle, data).each do |provider|
91
+ match = provider.regexp.match(url) or next
92
+ return resolved(bundle, provider, match)
93
+ end
94
+ end
95
+ nil
96
+ end
97
+
98
+ # Whether an installed bundle declares a provider (the web's "+ URL").
99
+ def any?
100
+ MemoryBundle::Provenance.each_installed.any? do |_bundle, data|
101
+ !data[:error] && data[:context_providers].is_a?(Array) && !data[:context_providers].empty?
102
+ end
103
+ end
104
+
105
+ def providers(bundle, data)
106
+ parse_list(data[:context_providers])
107
+ rescue Invalid => e
108
+ Log.warn(:context, "providers_unreadable", bundle: bundle, msg: e.message)
109
+ []
110
+ end
111
+
112
+ def resolved(bundle, provider, match)
113
+ url = match[0]
114
+ name = provider.name.gsub(/\\(\d)/) { match[Regexp.last_match(1).to_i].to_s }
115
+ begin
116
+ ContextSources.check_name!(name)
117
+ rescue ContextSources::Invalid
118
+ raise Invalid, "bundle #{bundle} makes #{name.inspect} of it, which isn't a source name"
119
+ end
120
+ Resolved.new(bundle: bundle, name: name, cmd: provider.cmd.gsub(URL) { Shellwords.escape(url) },
121
+ why: provider.why, hint: url, every_seconds: provider.every_seconds)
122
+ end
123
+
124
+ # +source+'s command as it runs: {bundle_dir} is its provider's
125
+ # installed folder, once that bundle is there and its scripts are as
126
+ # installed (they run outside the guardrails gate, as a plugin loads:
127
+ # checked the same way).
128
+ # @return [String]
129
+ # @raise [Invalid] the bundle is gone or a script changed
130
+ def command_for(source)
131
+ return source.cmd unless source.provider
132
+
133
+ provenance = MemoryBundle::Provenance.new(name: source.provider)
134
+ data = provenance.read
135
+ raise Invalid, "bundle #{source.provider} isn't installed" unless data
136
+
137
+ scripts = data[:scripts].is_a?(Hash) ? data[:scripts] : {}
138
+ scripts.each do |file, meta|
139
+ path = File.join(provenance.scripts_dir, file.to_s)
140
+ recorded = MemoryBundle::Provenance.recorded_sha(meta.is_a?(Hash) ? meta[:sha256] : nil)
141
+ next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
142
+
143
+ raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
144
+ end
145
+ source.cmd.gsub(BUNDLE_DIR) { Shellwords.escape(provenance.bundle_dir) }.gsub(RUBY) { Shellwords.escape(RbConfig.ruby) }
146
+ end
147
+
148
+ def every(value)
149
+ return nil if value.nil?
150
+
151
+ seconds = Integer(value.to_s, 10, exception: false)
152
+ unless seconds && seconds >= MIN_EVERY_SECONDS
153
+ raise Invalid, "context_providers: every_seconds is #{value.inspect}; it takes seconds, at least #{MIN_EVERY_SECONDS}"
154
+ end
155
+
156
+ seconds
157
+ end
158
+ end
159
+ end