samagotchi 0.30.0 → 0.32.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 +87 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +7 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- 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/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -1
- data/lib/samagotchi/child_reports.rb +212 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -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 +452 -0
- data/lib/samagotchi/engine.rb +12 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- 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/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- 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/prompt_warmup.rb +30 -5
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +30 -7
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +37 -5
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +48 -7
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
- data/lib/samagotchi/terminal_ui.rb +9 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +28 -7
- 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 +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -3
- data/lib/samagotchi/web/public/app.js +52 -14
- 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 +21 -0
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +34 -10
- data/lib/samagotchi/web/public/turn_view.js +26 -4
- data/lib/samagotchi/worker.rb +343 -8
- metadata +16 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "context_sources"
|
|
6
|
+
require_relative "context_fetch"
|
|
7
|
+
require_relative "context_providers"
|
|
8
|
+
require_relative "memory_paths"
|
|
9
|
+
require_relative "session"
|
|
10
|
+
require_relative "cli/command"
|
|
11
|
+
require_relative "cli/exit"
|
|
12
|
+
require_relative "cli/flags"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
# `chi context`: attach live external text to sessions (ContextSources).
|
|
16
|
+
# A source is a command chi runs every so often, or text pushed into it;
|
|
17
|
+
# the session's worker leaves a short note when it changes and the model
|
|
18
|
+
# reads the text with context_read.
|
|
19
|
+
class ContextCommand
|
|
20
|
+
include CLI::Command
|
|
21
|
+
|
|
22
|
+
USAGE = <<~TEXT
|
|
23
|
+
Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute> [options] [TARGET]
|
|
24
|
+
add NAME (--cmd CMD | --push) [--every SECONDS] [--why TEXT] [--hint TEXT] TARGET
|
|
25
|
+
add URL [--why TEXT] TARGET a URL an installed bundle's provider knows (github-pr: a PR)
|
|
26
|
+
a source: CMD prints its text (plain, or JSON {"text", "summary", "wake", "hint"});
|
|
27
|
+
--push: text comes from chi context push. --every: how often CMD runs
|
|
28
|
+
(seconds, at least 30; default context.every_seconds). --why: why it's attached;
|
|
29
|
+
--hint: one line, often its URL. NAME: a-z, 0-9 and -, up to 40.
|
|
30
|
+
push NAME [-m TEXT] [TARGET] new text for a --push source (stdin without -m; text or JSON)
|
|
31
|
+
ls [TARGET] [--format json] the sources, their age and state
|
|
32
|
+
show NAME [--json] [TARGET] the text (--json: the source and its snapshot)
|
|
33
|
+
refresh NAME [TARGET] run NAME's command now, here (a live worker absorbs the result)
|
|
34
|
+
rm NAME TARGET detach a source
|
|
35
|
+
mute|unmute NAME ID... a project's source, ignored by one session
|
|
36
|
+
TARGET: session ids or unique prefixes, or --project (this git repository's
|
|
37
|
+
sessions, all of them). Inside a chi session (its execute) the default is that
|
|
38
|
+
session. Find ids with: chi sessions list [--live]
|
|
39
|
+
TEXT
|
|
40
|
+
|
|
41
|
+
SUBCOMMANDS = %w[add push ls show refresh rm mute unmute].freeze
|
|
42
|
+
|
|
43
|
+
FLAGS = {
|
|
44
|
+
"add" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
45
|
+
f.value "--cmd"
|
|
46
|
+
f.switch "--push"
|
|
47
|
+
f.value "--every"
|
|
48
|
+
f.value "--why"
|
|
49
|
+
f.value "--hint"
|
|
50
|
+
f.switch "--project"
|
|
51
|
+
end,
|
|
52
|
+
"push" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
53
|
+
f.value "-m", "--message", key: :text
|
|
54
|
+
f.switch "--project"
|
|
55
|
+
end,
|
|
56
|
+
"ls" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
57
|
+
f.value "--format"
|
|
58
|
+
f.switch "--project"
|
|
59
|
+
end,
|
|
60
|
+
"show" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
61
|
+
f.switch "--json"
|
|
62
|
+
f.switch "--project"
|
|
63
|
+
end,
|
|
64
|
+
"refresh" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--project" },
|
|
65
|
+
"rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--project" },
|
|
66
|
+
"mute" => CLI::Flags.new(help: HELP_WORDS),
|
|
67
|
+
"unmute" => CLI::Flags.new(help: HELP_WORDS)
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# Where a command acts: a session (its own sources, then its project's)
|
|
71
|
+
# or a project's sources. +label+ names it in the output, +cwd+ is
|
|
72
|
+
# where its session's sources run (ContextPoller's choice).
|
|
73
|
+
Target = Data.define(:session_id, :project_root, :cwd, :label) do
|
|
74
|
+
def project? = session_id.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param argv [Array<String>] the arguments after "context"
|
|
78
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd)
|
|
79
|
+
@argv = argv.dup
|
|
80
|
+
@stdin = stdin
|
|
81
|
+
@stdout = stdout
|
|
82
|
+
@stderr = stderr
|
|
83
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
84
|
+
@env = env
|
|
85
|
+
@cwd = cwd
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
89
|
+
def run
|
|
90
|
+
@sub = @argv[0]
|
|
91
|
+
if @sub.nil? || HELP_WORDS.include?(@sub)
|
|
92
|
+
@stdout.puts(USAGE)
|
|
93
|
+
return 0
|
|
94
|
+
end
|
|
95
|
+
return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
|
|
96
|
+
|
|
97
|
+
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
98
|
+
return parsed if parsed.is_a?(Integer)
|
|
99
|
+
|
|
100
|
+
send(:"run_#{@sub}", parsed.options, parsed.args)
|
|
101
|
+
rescue ContextSources::Invalid => e
|
|
102
|
+
error_line("#{command_name}: #{e.message}")
|
|
103
|
+
CLI::Exit::FAILED
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi context #{@sub}" : "chi context"
|
|
109
|
+
def usage_on_error = nil
|
|
110
|
+
|
|
111
|
+
def run_add(options, args)
|
|
112
|
+
name = args.shift
|
|
113
|
+
return usage_error("give the source's NAME") unless name
|
|
114
|
+
return add_url(name, options, args) if name.match?(%r{\Ahttps?://})
|
|
115
|
+
return usage_error("give --cmd CMD or --push") unless options[:cmd] || options[:push]
|
|
116
|
+
return usage_error("--cmd and --push don't go together") if options[:cmd] && options[:push]
|
|
117
|
+
return usage_error("--every is for a --cmd source") if options[:every] && options[:push]
|
|
118
|
+
return usage_error("--cmd is empty") if options[:cmd] && options[:cmd].strip.empty?
|
|
119
|
+
|
|
120
|
+
ContextSources.check_name!(name)
|
|
121
|
+
source = ContextSources::Source.new(
|
|
122
|
+
name: name, cmd: options[:cmd], every_seconds: ContextSources.check_every!(options[:every]),
|
|
123
|
+
why: ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS),
|
|
124
|
+
hint: ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS),
|
|
125
|
+
scope: nil, added_by: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601
|
|
126
|
+
)
|
|
127
|
+
each_target(options, args) do |target|
|
|
128
|
+
location = location_of(target)
|
|
129
|
+
location.add(source.with(scope: location.scope))
|
|
130
|
+
@stdout.puts("#{target.label} attached #{name}")
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A URL through the installed bundles' providers (ContextProviders):
|
|
136
|
+
# the provider gives the name, command, hint and interval. Not gated
|
|
137
|
+
# like --cmd (chi-context-cmd): the command is the bundle's.
|
|
138
|
+
def add_url(url, options, args)
|
|
139
|
+
if options[:cmd] || options[:push] || options[:every]
|
|
140
|
+
return usage_error("a URL's command comes from its provider: give no --cmd, --push or --every")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
resolved = ContextProviders.resolve(url)
|
|
144
|
+
return fail_line("no installed bundle resolves #{url} (chi bundle install github-pr for GitHub PRs)") unless resolved
|
|
145
|
+
|
|
146
|
+
source = ContextSources::Source.new(
|
|
147
|
+
name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
|
|
148
|
+
why: ContextSources.one_line(options[:why] || resolved.why, ContextSources::LINE_MAX_CHARS),
|
|
149
|
+
hint: ContextSources.one_line(options[:hint] || resolved.hint, ContextSources::LINE_MAX_CHARS),
|
|
150
|
+
scope: nil, added_by: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601, provider: resolved.bundle
|
|
151
|
+
)
|
|
152
|
+
each_target(options, args) do |target|
|
|
153
|
+
location = location_of(target)
|
|
154
|
+
location.add(source.with(scope: location.scope))
|
|
155
|
+
@stdout.puts("#{target.label} attached #{source.name}")
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
rescue ContextProviders::Invalid => e
|
|
159
|
+
fail_line(e.message)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def run_push(options, args)
|
|
163
|
+
name = args.shift
|
|
164
|
+
return usage_error("give the source's NAME") unless name
|
|
165
|
+
|
|
166
|
+
text = utf8(options[:text] || read_stdin)
|
|
167
|
+
return usage_error("no text: pass -m TEXT or pipe it in") unless text
|
|
168
|
+
|
|
169
|
+
fetched = ContextSources.parse_output(text)
|
|
170
|
+
each_target(options, args) do |target|
|
|
171
|
+
attached = find(target, name) or next false
|
|
172
|
+
# A command source's text is its command's (R2): a push between
|
|
173
|
+
# two fetches would read as a change and then vanish.
|
|
174
|
+
unless attached.source.push?
|
|
175
|
+
error_line("#{command_name}: #{name} runs a command, which writes its text: push goes to a --push source")
|
|
176
|
+
next false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
before, written = attached.location.locked(name) do
|
|
180
|
+
[attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
|
|
181
|
+
end
|
|
182
|
+
@stdout.puts("#{target.label} #{name}: #{written.revision == before ? "unchanged" : "new text"} (#{written.revision[0, 12]})")
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def run_ls(options, args)
|
|
188
|
+
format = options[:format]
|
|
189
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
190
|
+
|
|
191
|
+
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
192
|
+
rows = targets.flat_map { |target| rows_for(target) }
|
|
193
|
+
if format == "json"
|
|
194
|
+
@stdout.puts(JSON.pretty_generate(rows))
|
|
195
|
+
elsif rows.empty?
|
|
196
|
+
@stdout.puts("no attached context")
|
|
197
|
+
else
|
|
198
|
+
print_rows(rows, labels: targets.size > 1)
|
|
199
|
+
end
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def run_show(options, args)
|
|
204
|
+
name = args.shift
|
|
205
|
+
return usage_error("give the source's NAME") unless name
|
|
206
|
+
|
|
207
|
+
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
208
|
+
return usage_error("show takes one target") if targets.size > 1
|
|
209
|
+
|
|
210
|
+
attached = find(targets.first, name) or return CLI::Exit::FAILED
|
|
211
|
+
snapshot = attached.snapshot
|
|
212
|
+
if options[:json]
|
|
213
|
+
@stdout.puts(JSON.pretty_generate({ "source" => attached.source.to_h, "snapshot" => snapshot.to_h }))
|
|
214
|
+
return 0
|
|
215
|
+
end
|
|
216
|
+
unless snapshot.text?
|
|
217
|
+
return fail_line("#{name} has no text yet#{" (last error: #{snapshot.error})" if snapshot.error}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
@stdout.write(snapshot.text)
|
|
221
|
+
@stdout.write("\n") unless snapshot.text.end_with?("\n")
|
|
222
|
+
0
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Runs the command in this process, not the worker's: the result shows
|
|
226
|
+
# here, and the worker sees the new snapshot on its next loop. The
|
|
227
|
+
# source's lock keeps the two from running it at once.
|
|
228
|
+
def run_refresh(options, args)
|
|
229
|
+
name = args.shift
|
|
230
|
+
return usage_error("give the source's NAME") unless name
|
|
231
|
+
|
|
232
|
+
each_target(options, args) do |target|
|
|
233
|
+
attached = find(target, name) or next false
|
|
234
|
+
if attached.source.push?
|
|
235
|
+
error_line("#{command_name}: #{name} is pushed (chi context push), it has no command to run")
|
|
236
|
+
next false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
cwd = attached.location.session? ? target.cwd : ContextSources.project_cwd(target.project_root, target.cwd)
|
|
240
|
+
outcome = ContextFetch.fetch(attached, cwd: cwd)
|
|
241
|
+
refresh_line(target, name, outcome)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def refresh_line(target, name, outcome)
|
|
246
|
+
case outcome.status
|
|
247
|
+
when :new, :same
|
|
248
|
+
@stdout.puts("#{target.label} #{name}: #{outcome.status == :new ? "new text" : "unchanged"} " \
|
|
249
|
+
"(#{outcome.snapshot.revision[0, 12]})")
|
|
250
|
+
true
|
|
251
|
+
when :busy
|
|
252
|
+
error_line("#{command_name}: #{name} is being fetched right now (by its worker); try again in a moment")
|
|
253
|
+
false
|
|
254
|
+
when :gone
|
|
255
|
+
error_line("#{command_name}: #{name} was removed while it ran; nothing was kept")
|
|
256
|
+
false
|
|
257
|
+
else
|
|
258
|
+
error_line("#{command_name}: #{name} failed: #{outcome.error}")
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def run_rm(options, args)
|
|
264
|
+
name = args.shift
|
|
265
|
+
return usage_error("give the source's NAME") unless name
|
|
266
|
+
|
|
267
|
+
ContextSources.check_name!(name)
|
|
268
|
+
each_target(options, args) do |target|
|
|
269
|
+
location = location_of(target)
|
|
270
|
+
if location.remove(name)
|
|
271
|
+
@stdout.puts("#{target.label} detached #{name}")
|
|
272
|
+
next true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
276
|
+
if project&.source(name)
|
|
277
|
+
error_line("#{command_name}: #{name} is the project's: chi context rm #{name} --project, or mute it for this session")
|
|
278
|
+
else
|
|
279
|
+
error_line("#{command_name}: no source #{name} for #{target.label}")
|
|
280
|
+
end
|
|
281
|
+
false
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def run_mute(options, args) = mute_or_unmute(options, args, mute: true)
|
|
286
|
+
def run_unmute(options, args) = mute_or_unmute(options, args, mute: false)
|
|
287
|
+
|
|
288
|
+
def mute_or_unmute(_options, args, mute:)
|
|
289
|
+
name = args.shift
|
|
290
|
+
return usage_error("give the source's NAME") unless name
|
|
291
|
+
return usage_error("give session ids") if args.empty?
|
|
292
|
+
|
|
293
|
+
ContextSources.check_name!(name)
|
|
294
|
+
each_target({}, args) do |target|
|
|
295
|
+
own = location_of(target)
|
|
296
|
+
if mute
|
|
297
|
+
find(target, name) or next false
|
|
298
|
+
own.mute(name)
|
|
299
|
+
else
|
|
300
|
+
own.unmute(name)
|
|
301
|
+
end
|
|
302
|
+
@stdout.puts("#{target.label} #{mute ? "muted" : "unmuted"} #{name}")
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @yield [Target] each target; the block returns whether it worked
|
|
308
|
+
# @return [Integer] exit status
|
|
309
|
+
def each_target(options, args, &)
|
|
310
|
+
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
311
|
+
targets.map(&).all? ? 0 : CLI::Exit::FAILED
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# @return [Array<Target>, nil] nil after an error line
|
|
315
|
+
def targets(options, args)
|
|
316
|
+
if options[:project]
|
|
317
|
+
unless args.empty?
|
|
318
|
+
usage_error("--project takes no session ids")
|
|
319
|
+
return nil
|
|
320
|
+
end
|
|
321
|
+
target = project_target or return nil
|
|
322
|
+
return [target]
|
|
323
|
+
end
|
|
324
|
+
if args.empty?
|
|
325
|
+
own = inside_session
|
|
326
|
+
unless own
|
|
327
|
+
error_line("#{command_name}: give session ids (or unique prefixes), or --project; " \
|
|
328
|
+
"inside a chi session it's that session")
|
|
329
|
+
return nil
|
|
330
|
+
end
|
|
331
|
+
args = [own]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
resolved = args.uniq.map { |given| session_target(given) }
|
|
335
|
+
resolved.all? ? resolved.uniq : nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def project_target
|
|
339
|
+
unless MemoryPaths.in_repo?(@cwd)
|
|
340
|
+
error_line("#{command_name}: --project needs a git repository, and #{@cwd} is in none")
|
|
341
|
+
return nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
root = MemoryPaths.project_root(@cwd)
|
|
345
|
+
# Its sources run in the root, or in this checkout when the root is a
|
|
346
|
+
# git dir (ContextSources.project_cwd).
|
|
347
|
+
Target.new(session_id: nil, project_root: root, cwd: MemoryPaths.work_tree_root(@cwd) || @cwd,
|
|
348
|
+
label: "project #{File.basename(root)}")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def session_target(given)
|
|
352
|
+
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
353
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
354
|
+
Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
|
|
355
|
+
rescue ArgumentError => e
|
|
356
|
+
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
357
|
+
error_line("#{command_name}: #{message}")
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The session chi's execute runs this in (SAMAGOTCHI_PARENT_SESSION;
|
|
362
|
+
# "chi" is a run with no session).
|
|
363
|
+
def inside_session
|
|
364
|
+
id = @env.fetch("SAMAGOTCHI_PARENT_SESSION", "").to_s.strip
|
|
365
|
+
id.empty? || id == "chi" ? nil : id
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The folder a target's own sources live in.
|
|
369
|
+
def location_of(target)
|
|
370
|
+
if target.project?
|
|
371
|
+
ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
372
|
+
else
|
|
373
|
+
ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# A source as the target sees it (a session: its own, then its project's).
|
|
378
|
+
# @return [ContextSources::Attached, nil] nil after an error line
|
|
379
|
+
def find(target, name)
|
|
380
|
+
ContextSources.check_name!(name)
|
|
381
|
+
found = if target.project?
|
|
382
|
+
location = location_of(target)
|
|
383
|
+
source = location.source(name)
|
|
384
|
+
source && ContextSources::Attached.new(source: source, location: location, shadowed: false)
|
|
385
|
+
else
|
|
386
|
+
attached_to(target).find { |a| a.name == name }
|
|
387
|
+
end
|
|
388
|
+
error_line("#{command_name}: no source #{name} for #{target.label}") unless found
|
|
389
|
+
found
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def attached_to(target, shadowed: false)
|
|
393
|
+
ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
|
|
394
|
+
shadowed: shadowed)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def rows_for(target)
|
|
398
|
+
if target.project?
|
|
399
|
+
location = location_of(target)
|
|
400
|
+
return location.sources.map { |source| row(target, source, location.snapshot(source.name)) }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
own = ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
404
|
+
subs = own.subscriptions
|
|
405
|
+
attached_to(target, shadowed: true).map do |a|
|
|
406
|
+
snapshot = a.snapshot
|
|
407
|
+
read = subs[a.name]&.read
|
|
408
|
+
row(target, a.source, snapshot, muted: own.muted?(a.name), shadowed: a.shadowed,
|
|
409
|
+
unread: snapshot.text? && read != snapshot.revision)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def row(target, source, snapshot, muted: nil, shadowed: nil, unread: nil)
|
|
414
|
+
{ "target" => target.label, "session_id" => target.session_id, "name" => source.name, "scope" => source.scope,
|
|
415
|
+
"cmd" => source.cmd, "every_seconds" => source.every_seconds, "why" => source.why, "hint" => source.hint,
|
|
416
|
+
"added_by" => source.added_by, "created_at" => source.created_at, "fetched_at" => snapshot.fetched_at,
|
|
417
|
+
"revision" => snapshot.revision, "summary" => snapshot.summary, "error" => snapshot.error,
|
|
418
|
+
"muted" => muted, "shadowed" => shadowed, "unread" => unread }.compact
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def print_rows(rows, labels:)
|
|
422
|
+
lines = rows.map do |r|
|
|
423
|
+
kind = r["cmd"] ? "cmd every #{r["every_seconds"] || "default"}#{"s" if r["every_seconds"]}" : "push"
|
|
424
|
+
state = if r["shadowed"] then "shadowed"
|
|
425
|
+
elsif r["muted"] then "muted"
|
|
426
|
+
elsif r["error"] then "error: #{r["error"]}"
|
|
427
|
+
elsif r["revision"].nil? then "no text yet"
|
|
428
|
+
else
|
|
429
|
+
"ok"
|
|
430
|
+
end
|
|
431
|
+
[*(labels ? [r["target"]] : []), r["name"], r["scope"], kind, age(r["fetched_at"]), state, r["why"].to_s]
|
|
432
|
+
end
|
|
433
|
+
widths = lines.transpose.map { |column| column.map(&:length).max }
|
|
434
|
+
lines.each { |line| @stdout.puts(line.zip(widths).map { |cell, w| cell.ljust(w) }.join(" ").rstrip) }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def age(iso)
|
|
438
|
+
return "-" unless iso
|
|
439
|
+
|
|
440
|
+
seconds = (Time.now - Time.iso8601(iso)).to_i
|
|
441
|
+
if seconds < 60 then "#{seconds}s ago"
|
|
442
|
+
elsif seconds < 3600 then "#{seconds / 60}m ago"
|
|
443
|
+
elsif seconds < 86_400 then "#{seconds / 3600}h ago"
|
|
444
|
+
else
|
|
445
|
+
"#{seconds / 86_400}d ago"
|
|
446
|
+
end
|
|
447
|
+
rescue ArgumentError
|
|
448
|
+
"-"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def fail_line(message)
|
|
452
|
+
error_line("#{command_name}: #{message}")
|
|
453
|
+
CLI::Exit::FAILED
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Only a pipe or a file is read, as chi note does: a terminal or a
|
|
457
|
+
# socket would hang a script.
|
|
458
|
+
def read_stdin
|
|
459
|
+
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
460
|
+
|
|
461
|
+
if @stdin.respond_to?(:stat)
|
|
462
|
+
stat = @stdin.stat
|
|
463
|
+
return nil unless stat.pipe? || stat.file?
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
@stdin.read
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def utf8(text)
|
|
470
|
+
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "context_sources"
|
|
5
|
+
require_relative "context_providers"
|
|
6
|
+
require_relative "log"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# Runs an attached context source's command once and records the result
|
|
10
|
+
# in its snapshot (ContextSources): the worker's ContextPoller and
|
|
11
|
+
# `chi context refresh` both come here.
|
|
12
|
+
#
|
|
13
|
+
# The command runs with `sh -c` in its own process group (a timeout, the
|
|
14
|
+
# output cap or the caller's cancel kills the group: no orphans), in
|
|
15
|
+
# +cwd+, with stdin closed, SAMAGOTCHI_CONTEXT_NAME and (once there is
|
|
16
|
+
# one) SAMAGOTCHI_CONTEXT_PREVIOUS, the last snapshot's path. stdout is
|
|
17
|
+
# the text (ContextSources.parse_output); stderr goes to the debug log,
|
|
18
|
+
# its last line into the error. A non-blocking flock on <name>.lock keeps
|
|
19
|
+
# two workers (or a worker and the CLI) from running one source at once.
|
|
20
|
+
module ContextFetch
|
|
21
|
+
TIMEOUT_SECONDS = 60
|
|
22
|
+
STDERR_TAIL_BYTES = 4096
|
|
23
|
+
READ_CHUNK = 64 * 1024
|
|
24
|
+
# How long a group gets after TERM before KILL.
|
|
25
|
+
KILL_GRACE_SECONDS = 1.0
|
|
26
|
+
OVER_CAP = "it printed more than 1 MiB"
|
|
27
|
+
|
|
28
|
+
# +status+: :new (a new revision), :same (the same text), :error (the
|
|
29
|
+
# fetch failed: +error+), :busy (another process holds the lock),
|
|
30
|
+
# :fresh (tried within +fresh_within+, by someone else meanwhile),
|
|
31
|
+
# :cancelled (the caller stopped it: nothing recorded, since a worker
|
|
32
|
+
# leaving isn't the source failing), :gone (the source was removed or
|
|
33
|
+
# replaced while it ran: nothing recorded for it).
|
|
34
|
+
Outcome = Data.define(:status, :snapshot, :error)
|
|
35
|
+
# What the command did: +output+ its stdout, +error+ nil or why it
|
|
36
|
+
# failed, +cancelled+ the caller stopped it.
|
|
37
|
+
Run = Data.define(:output, :error, :stderr, :cancelled)
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param attached [ContextSources::Attached] a command source
|
|
42
|
+
# @param fresh_within [Numeric, nil] skip it when it was tried this
|
|
43
|
+
# many seconds ago (checked under the lock)
|
|
44
|
+
# @param cancelled [#call] true: kill the command (the worker stops)
|
|
45
|
+
# @return [Outcome]
|
|
46
|
+
def fetch(attached, cwd:, fresh_within: nil, timeout: TIMEOUT_SECONDS, cancelled: -> { false })
|
|
47
|
+
location = attached.location
|
|
48
|
+
name = attached.name
|
|
49
|
+
File.open(location.lock_path(name), File::RDWR | File::CREAT, 0o644) do |lock|
|
|
50
|
+
return Outcome.new(status: :busy, snapshot: nil, error: nil) unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
51
|
+
|
|
52
|
+
before = location.snapshot(name)
|
|
53
|
+
age = before.age
|
|
54
|
+
return Outcome.new(status: :fresh, snapshot: before, error: nil) if fresh_within && age && age < fresh_within
|
|
55
|
+
|
|
56
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
57
|
+
run = begin
|
|
58
|
+
run_command(ContextProviders.command_for(attached.source), cwd: cwd, env: env_for(location, name),
|
|
59
|
+
timeout: timeout, cancelled: cancelled)
|
|
60
|
+
rescue ContextProviders::Invalid => e
|
|
61
|
+
Run.new(output: nil, error: e.message, stderr: "", cancelled: false)
|
|
62
|
+
end
|
|
63
|
+
outcome = if run.cancelled
|
|
64
|
+
Outcome.new(status: :cancelled, snapshot: before, error: nil)
|
|
65
|
+
elsif !current?(attached)
|
|
66
|
+
Outcome.new(status: :gone, snapshot: nil, error: nil)
|
|
67
|
+
else
|
|
68
|
+
record_current(attached, run, before)
|
|
69
|
+
end
|
|
70
|
+
Log.info(:context, "fetched", name: name, scope: location.scope, status: outcome.status,
|
|
71
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
72
|
+
error: outcome.error)
|
|
73
|
+
outcome
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether the source is still the one this fetch ran: `chi context rm`
|
|
78
|
+
# (or rm and add with another command) doesn't take the lock.
|
|
79
|
+
def current?(attached) = attached.location.source(attached.name) == attached.source
|
|
80
|
+
|
|
81
|
+
# #record, then the check again: a removal between the first check and
|
|
82
|
+
# the write leaves no snapshot behind (the lock is still ours, so the
|
|
83
|
+
# file is ours too).
|
|
84
|
+
def record_current(attached, run, before)
|
|
85
|
+
outcome = record(attached.location, attached.name, run, before)
|
|
86
|
+
return outcome if current?(attached)
|
|
87
|
+
|
|
88
|
+
FileUtils.rm_f(attached.location.snapshot_path(attached.name))
|
|
89
|
+
Outcome.new(status: :gone, snapshot: nil, error: nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def record(location, name, run, before)
|
|
93
|
+
if run.error
|
|
94
|
+
snapshot = location.record_error(name, run.error)
|
|
95
|
+
return Outcome.new(status: :error, snapshot: snapshot, error: snapshot.error)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
fetched = ContextSources.parse_output(run.output)
|
|
100
|
+
rescue ContextSources::Invalid => e
|
|
101
|
+
snapshot = location.record_error(name, e.message)
|
|
102
|
+
return Outcome.new(status: :error, snapshot: snapshot, error: snapshot.error)
|
|
103
|
+
end
|
|
104
|
+
snapshot = location.record_text(name, fetched)
|
|
105
|
+
Outcome.new(status: snapshot.revision == before.revision ? :same : :new, snapshot: snapshot, error: nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def env_for(location, name)
|
|
109
|
+
previous = location.snapshot_path(name)
|
|
110
|
+
{ "SAMAGOTCHI_CONTEXT_NAME" => name,
|
|
111
|
+
"SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Run]
|
|
115
|
+
def run_command(cmd, cwd:, env:, timeout:, cancelled:)
|
|
116
|
+
return Run.new(output: nil, error: "its folder #{cwd} is gone", stderr: "", cancelled: false) unless cwd && Dir.exist?(cwd)
|
|
117
|
+
|
|
118
|
+
out_r, out_w = IO.pipe
|
|
119
|
+
err_r, err_w = IO.pipe
|
|
120
|
+
pid = Process.spawn(env, "sh", "-c", cmd, chdir: cwd, pgroup: true, in: File::NULL, out: out_w, err: err_w)
|
|
121
|
+
out_w.close
|
|
122
|
+
err_w.close
|
|
123
|
+
output = +""
|
|
124
|
+
over = false
|
|
125
|
+
out_reader = Thread.new do
|
|
126
|
+
loop do
|
|
127
|
+
chunk = out_r.readpartial(READ_CHUNK)
|
|
128
|
+
if output.bytesize + chunk.bytesize > ContextSources::TEXT_MAX_BYTES
|
|
129
|
+
over = true
|
|
130
|
+
break
|
|
131
|
+
end
|
|
132
|
+
output << chunk
|
|
133
|
+
end
|
|
134
|
+
rescue IOError # EOFError too
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
stderr = +""
|
|
138
|
+
err_reader = Thread.new do
|
|
139
|
+
loop do
|
|
140
|
+
stderr << err_r.readpartial(READ_CHUNK)
|
|
141
|
+
stderr = stderr.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) if stderr.bytesize > STDERR_TAIL_BYTES
|
|
142
|
+
end
|
|
143
|
+
rescue IOError # EOFError too
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
error, status = wait(pid, timeout: timeout, over: -> { over }, cancelled: cancelled)
|
|
148
|
+
reaped = true
|
|
149
|
+
# Whatever the command left running in its group goes with it.
|
|
150
|
+
signal_group(pid, "KILL")
|
|
151
|
+
[out_reader, err_reader].each { |thread| thread.join(KILL_GRACE_SECONDS) || thread.kill }
|
|
152
|
+
stderr_text = stderr.dup.force_encoding(Encoding::UTF_8).scrub
|
|
153
|
+
Log.debug(:context, "stderr", text: stderr_text[-1000..] || stderr_text) unless stderr_text.strip.empty?
|
|
154
|
+
return Run.new(output: nil, error: nil, stderr: stderr_text, cancelled: true) if error == :cancelled
|
|
155
|
+
|
|
156
|
+
# Checked again once the reader is done: a command that exits before
|
|
157
|
+
# wait's next tick never gets an over from it.
|
|
158
|
+
error = OVER_CAP if over
|
|
159
|
+
error ||= exit_error(status, stderr_text)
|
|
160
|
+
Run.new(output: output, error: error, stderr: stderr_text, cancelled: false)
|
|
161
|
+
rescue SystemCallError => e
|
|
162
|
+
Run.new(output: nil, error: "couldn't run it: #{e.message}", stderr: "", cancelled: false)
|
|
163
|
+
ensure
|
|
164
|
+
# Cut short (Ctrl-C in chi context refresh, an exception, a killed
|
|
165
|
+
# poller thread): the group doesn't get the terminal's SIGINT
|
|
166
|
+
# (pgroup), so stop and reap it here.
|
|
167
|
+
abandon(pid, [out_reader, err_reader]) if pid && !reaped
|
|
168
|
+
[out_r, out_w, err_r, err_w].each { |io| io&.close unless io&.closed? }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @return [Array(String|Symbol|nil, Process::Status|nil)] why it was
|
|
172
|
+
# stopped (:cancelled for the caller), and its status
|
|
173
|
+
def wait(pid, timeout:, over:, cancelled:)
|
|
174
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
175
|
+
loop do
|
|
176
|
+
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
177
|
+
return [nil, status] if status
|
|
178
|
+
|
|
179
|
+
reason = if over.call then OVER_CAP
|
|
180
|
+
elsif cancelled.call then :cancelled
|
|
181
|
+
elsif Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline then "timed out after #{timeout.round} s"
|
|
182
|
+
end
|
|
183
|
+
return [reason, stop_group(pid)] if reason
|
|
184
|
+
|
|
185
|
+
sleep(0.05)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def abandon(pid, readers)
|
|
190
|
+
stop_group(pid)
|
|
191
|
+
readers.each { |thread| thread&.kill }
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
Log.warn(:context, "stop_failed", pid: pid, error: e.class.name)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# TERM, then KILL after a grace; @return [Process::Status, nil]
|
|
197
|
+
def stop_group(pid)
|
|
198
|
+
signal_group(pid, "TERM")
|
|
199
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE_SECONDS
|
|
200
|
+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
201
|
+
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
202
|
+
return status if status
|
|
203
|
+
|
|
204
|
+
sleep(0.05)
|
|
205
|
+
end
|
|
206
|
+
signal_group(pid, "KILL")
|
|
207
|
+
Process.wait2(pid).last
|
|
208
|
+
rescue Errno::ECHILD
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def signal_group(pid, signal)
|
|
213
|
+
Process.kill(signal, -pid)
|
|
214
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def exit_error(status, stderr)
|
|
219
|
+
return nil if status.nil? || status.success?
|
|
220
|
+
|
|
221
|
+
last = stderr.lines.map(&:strip).reject(&:empty?).last
|
|
222
|
+
code = status.exitstatus ? "exit #{status.exitstatus}" : "killed by signal #{status.termsig}"
|
|
223
|
+
last ? "#{code}: #{last}" : code
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|