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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +220 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +7 -3
- data/docs/plugins.md +79 -2
- data/docs/releasing.md +3 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +31 -12
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +180 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +195 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +462 -0
- data/lib/samagotchi/engine.rb +19 -11
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -4
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +7 -6
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +36 -19
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
- data/lib/samagotchi/terminal_ui.rb +2 -4
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +111 -2
- data/lib/samagotchi/web/public/app.js +57 -28
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -3
- data/lib/samagotchi/web/public/turn_view.js +5 -2
- data/lib/samagotchi/worker.rb +154 -6
- 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
|
-
|
|
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,
|
|
23
|
-
|
|
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
|