samagotchi 0.31.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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 +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/plugins.md +77 -1
  11. data/docs/releasing.md +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  16. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  23. data/lib/samagotchi/child_reports.rb +0 -2
  24. data/lib/samagotchi/config.rb +6 -0
  25. data/lib/samagotchi/context_absorber.rb +179 -0
  26. data/lib/samagotchi/context_command.rb +473 -0
  27. data/lib/samagotchi/context_fetch.rb +226 -0
  28. data/lib/samagotchi/context_note.rb +11 -5
  29. data/lib/samagotchi/context_poller.rb +100 -0
  30. data/lib/samagotchi/context_providers.rb +159 -0
  31. data/lib/samagotchi/context_sources.rb +452 -0
  32. data/lib/samagotchi/engine.rb +10 -5
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  37. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  38. data/lib/samagotchi/log_line.rb +1 -1
  39. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  40. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  41. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  42. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  43. data/lib/samagotchi/plugin/context.rb +22 -0
  44. data/lib/samagotchi/question_desk.rb +0 -3
  45. data/lib/samagotchi/session.rb +7 -5
  46. data/lib/samagotchi/session_commands.rb +18 -1
  47. data/lib/samagotchi/session_manager.rb +5 -2
  48. data/lib/samagotchi/session_retention.rb +3 -0
  49. data/lib/samagotchi/steer.rb +12 -3
  50. data/lib/samagotchi/system_prompt.rb +1 -1
  51. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
  52. data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
  53. data/lib/samagotchi/terminal_ui.rb +0 -3
  54. data/lib/samagotchi/tool_activity.rb +8 -0
  55. data/lib/samagotchi/tool_declarations.rb +21 -0
  56. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  57. data/lib/samagotchi/tools/builtins.rb +5 -0
  58. data/lib/samagotchi/tools/context_read.rb +163 -0
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +109 -1
  61. data/lib/samagotchi/web/public/app.js +40 -10
  62. data/lib/samagotchi/web/public/composer_size.js +7 -6
  63. data/lib/samagotchi/web/public/context_chips.js +285 -0
  64. data/lib/samagotchi/web/public/data.js +21 -0
  65. data/lib/samagotchi/web/public/index.html +49 -3
  66. data/lib/samagotchi/web/public/notify.js +5 -3
  67. data/lib/samagotchi/web/public/timing.js +7 -3
  68. data/lib/samagotchi/web/public/turn_events.js +8 -2
  69. data/lib/samagotchi/web/public/turn_view.js +4 -1
  70. data/lib/samagotchi/worker.rb +126 -0
  71. metadata +14 -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