samagotchi 0.158.0 → 0.159.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -6
  5. data/docs/cli.md +56 -1
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +3 -1
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/bridge.md +1 -0
  11. data/docs/internals/bundles.md +9 -7
  12. data/docs/memory.md +5 -1
  13. data/docs/scheduling.md +228 -0
  14. data/docs/sessions.md +25 -1
  15. data/lib/samagotchi/bridge.rb +26 -2
  16. data/lib/samagotchi/bundle_command.rb +38 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +13 -3
  19. data/lib/samagotchi/config.rb +12 -0
  20. data/lib/samagotchi/context_move.rb +74 -0
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  22. data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
  23. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  24. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
  26. data/lib/samagotchi/memory_bundle/status.rb +20 -1
  27. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  28. data/lib/samagotchi/run_options.rb +1 -0
  29. data/lib/samagotchi/schedule_command.rb +252 -0
  30. data/lib/samagotchi/schedules.rb +948 -0
  31. data/lib/samagotchi/session_access/contract.rb +1 -1
  32. data/lib/samagotchi/session_access/local.rb +20 -0
  33. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  34. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  35. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +43 -0
  37. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  38. data/lib/samagotchi/tools/builtins.rb +17 -0
  39. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  40. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  41. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/public/chains.js +32 -8
  44. data/lib/samagotchi/web/public/index.html +4 -1
  45. data/lib/samagotchi/web/public/info_bar.js +4 -3
  46. data/lib/samagotchi/web/public/turn_events.js +4 -0
  47. data/lib/samagotchi/web/session_hub.rb +20 -0
  48. data/lib/samagotchi/worker.rb +24 -6
  49. data/lib/samagotchi/worker_boot.rb +3 -4
  50. data/lib/samagotchi/worker_inbound.rb +23 -0
  51. data/lib/samagotchi/worker_link.rb +5 -0
  52. data/lib/samagotchi/worker_wakes.rb +9 -1
  53. metadata +8 -1
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "cli/command"
7
+ require_relative "cli/exit"
8
+ require_relative "cli/flags"
9
+ require_relative "schedules"
10
+ require_relative "session"
11
+
12
+ module Samagotchi
13
+ # `chi schedule`: durable schedules that outlive every worker (docs/scheduling.md).
14
+ # A tick run by cron, systemd or chi web's hub (`chi schedule tick`) fires the
15
+ # entries whose time has come against a session: the next link of its chain
16
+ # (the default), or a queued turn in the session itself.
17
+ # bin/chi dispatches here before OptionParser; this machine only.
18
+ class ScheduleCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi schedule <add|list|show|tick|run-now|enable|disable|cancel> [flags]
23
+
24
+ add --session ID (--continues ID) (-m|--message|--prompt TEXT) [--title TEXT]
25
+ one of: --every 30m | --weekday 1 --time 09:00 | --date 2026-11-20T09:00
26
+ [--mode chain|same] [--catch-up once|none] [--keep-links N] [--max-failures N] [--max-skips N]
27
+ list [--all] [--format text|json]
28
+ show ID
29
+ tick [--dry-run] [--format text|json]
30
+ run-now ID
31
+ enable ID | disable ID | cancel ID
32
+
33
+ Recurrence: --every 45m / 2h / 1d (UTC interval math: an `every 1d` schedule drifts
34
+ an hour across a DST change — use --weekday for a wall-clock time);
35
+ --weekday 1..7 (1=Mon) with --time HH:MM in the machine's local zone;
36
+ --date one local moment.
37
+ Modes: chain (default) runs the prompt as the next link of the session's chain;
38
+ same queues it as a turn in the session itself.
39
+ A busy target is skipped and retried at the next tick; after max_skips skips
40
+ (default 12) or max_failures failures (default 3) the schedule is disabled.
41
+ TEXT
42
+
43
+ SUBCOMMANDS = %w[add list show tick run-now enable disable cancel].freeze
44
+
45
+ # A usage error names the subcommand it was given (CLI::Command's
46
+ # usage_error builds its line from this).
47
+ def command_name = SUBCOMMANDS.include?(@argv.first.to_s) ? "chi schedule #{@argv.first}" : "chi schedule"
48
+
49
+ FLAGS = {
50
+ "add" => CLI::Flags.new(args: true) do |f|
51
+ f.value "--session", "--continues", key: :target
52
+ f.value "-m", "--message", "--prompt", key: :prompt
53
+ f.value "--title"
54
+ f.value "--every"
55
+ f.value "--weekday"
56
+ f.value "--time"
57
+ f.value "--date"
58
+ f.value "--mode"
59
+ f.value "--catch-up"
60
+ f.value "--keep-links"
61
+ f.value "--max-failures"
62
+ f.value "--max-skips"
63
+ end,
64
+ "list" => CLI::Flags.new(args: false) do |f|
65
+ f.switch "--all", key: :include_disabled
66
+ f.value "--format"
67
+ end,
68
+ "show" => CLI::Flags.new(args: true),
69
+ "tick" => CLI::Flags.new(args: false) do |f|
70
+ f.switch "--dry-run"
71
+ f.value "--format"
72
+ end,
73
+ "run-now" => CLI::Flags.new(args: true),
74
+ "enable" => CLI::Flags.new(args: true),
75
+ "disable" => CLI::Flags.new(args: true),
76
+ "cancel" => CLI::Flags.new(args: true)
77
+ }.freeze
78
+
79
+ # @param argv [Array<String>] the arguments after "schedule"
80
+ # @param schedules [Schedules, nil] the store; default this machine's
81
+ def initialize(argv, stdout: $stdout, stderr: $stderr, schedules: nil)
82
+ @argv = argv.dup
83
+ @stdout = stdout
84
+ @stderr = stderr
85
+ @schedules = schedules || Schedules.new
86
+ end
87
+
88
+ # @return [Integer] exit status: 0 ok, 1 a missing session or schedule,
89
+ # 2 a usage error
90
+ def run
91
+ sub = @argv[0]
92
+ if sub.nil? || HELP_WORDS.include?(sub)
93
+ @stdout.puts(USAGE)
94
+ return 0
95
+ end
96
+ return usage_error("unknown subcommand #{sub}") unless SUBCOMMANDS.include?(sub)
97
+
98
+ parsed = parse_flags(FLAGS.fetch(sub), @argv[1..])
99
+ return parsed if parsed.is_a?(Integer)
100
+
101
+ @opts = parsed.options
102
+ @args = parsed.args
103
+
104
+ case sub
105
+ when "add" then add
106
+ when "list" then list
107
+ when "show" then show
108
+ when "tick" then tick
109
+ when "run-now" then act { |id| @schedules.run_now(id) }
110
+ when "enable" then act { |id| @schedules.enable(id) }
111
+ when "disable" then act { |id| @schedules.disable(id) }
112
+ when "cancel" then act { |id| @schedules.cancel(id) }
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def add
119
+ if @opts[:target].to_s.strip.empty? && !@args.empty?
120
+ return usage_error("add takes the session with --session ID, not #{@args.map { |arg| arg.inspect }.join(" ")}")
121
+ end
122
+ return usage_error("add needs --session ID (the session the schedule runs against)") if @opts[:target].to_s.strip.empty?
123
+ return usage_error("add needs a prompt: -m \"...\"") if @opts[:prompt].to_s.strip.empty?
124
+
125
+ begin
126
+ recurrence = Schedules.recurrence(every: @opts[:every], weekday: @opts[:weekday], time: @opts[:time], date: @opts[:date])
127
+ entry = @schedules.add(target: @opts[:target], prompt: @opts[:prompt], recurrence: recurrence,
128
+ title: @opts[:title], mode: @opts[:mode] || Schedules::CHAIN,
129
+ catch_up: @opts[:catch_up], keep_links: @opts[:keep_links],
130
+ max_failures: @opts[:max_failures], max_skips: @opts[:max_skips])
131
+ rescue Schedules::Usage => e
132
+ return usage_error(e.message)
133
+ rescue Session::AmbiguousId => e
134
+ return failed("which session? #{e.message}")
135
+ rescue Session::NotFound
136
+ return failed("no session #{@opts[:target]}")
137
+ end
138
+
139
+ @stdout.puts("#{entry.id} added: #{entry.target_words} · #{entry.recurrence_words} · next #{clock(entry.next_due)}")
140
+ CLI::Exit::OK
141
+ end
142
+
143
+ def list
144
+ entries = @schedules.entries(include_disabled: @opts[:include_disabled] ? true : false)
145
+ if @opts[:format] == "json"
146
+ @stdout.puts(JSON.pretty_generate(entries.map { |entry| entry_line_json(entry) }))
147
+ return CLI::Exit::OK
148
+ end
149
+ if entries.empty?
150
+ @stdout.puts("no schedules")
151
+ return CLI::Exit::OK
152
+ end
153
+ entries.each { |entry| @stdout.puts(entry_line(entry)) }
154
+ CLI::Exit::OK
155
+ end
156
+
157
+ def show
158
+ id = @args.first || @opts[:id]
159
+ return usage_error("show needs a schedule id") if id.to_s.strip.empty?
160
+
161
+ entry = @schedules.lookup(id)
162
+ return failed("no schedule #{id}") if entry.nil?
163
+
164
+ @stdout.puts("#{entry.id} #{entry.title || "(untitled)"}")
165
+ @stdout.puts(" anchor: #{entry.anchor} head: #{entry.head} mode: #{entry.mode}")
166
+ @stdout.puts(" recurrence: #{entry.recurrence_words} next due: #{clock(entry.next_due)}")
167
+ @stdout.puts(" last fired: #{clock(entry.last_fired)} last result: #{entry.last_result || "-"}")
168
+ @stdout.puts(" skips: #{entry.skips} of #{entry.max_skips} failures: #{entry.failures} of #{entry.max_failures}")
169
+ @stdout.puts(" keep_links: #{entry.keep_links} enabled: #{entry.enabled}")
170
+ @stdout.puts(" prompt: #{entry.prompt}")
171
+ CLI::Exit::OK
172
+ rescue Schedules::Usage => e
173
+ usage_error(e.message)
174
+ end
175
+
176
+ def tick
177
+ result = @schedules.tick(dry_run: @opts[:dry_run] ? true : false)
178
+ if @opts[:format] == "json"
179
+ @stdout.puts(JSON.pretty_generate({ "locked" => result.locked, "outcomes" => result.outcomes.map { |outcome| { "id" => outcome.id, "status" => outcome.status, "detail" => outcome.detail } } }))
180
+ return CLI::Exit::OK
181
+ end
182
+
183
+ if result.locked
184
+ @stdout.puts("no schedules due (another tick holds the lock)")
185
+ return CLI::Exit::OK
186
+ end
187
+ if result.outcomes.empty?
188
+ @stdout.puts("no schedules due")
189
+ return CLI::Exit::OK
190
+ end
191
+
192
+ result.outcomes.each do |outcome|
193
+ word = outcome.status == "dry" ? "would fire" : outcome.status
194
+ @stdout.puts("#{outcome.id} #{word} #{outcome.detail}")
195
+ end
196
+ CLI::Exit::OK
197
+ end
198
+
199
+ def act
200
+ id = @args.first
201
+ return usage_error("#{@argv[0]} needs a schedule id") if id.to_s.strip.empty?
202
+
203
+ result = yield(id)
204
+ return failed("no schedule #{id}") if result.nil?
205
+
206
+ if result.is_a?(Schedules::Outcome)
207
+ @stdout.puts("#{id}: #{result.status} · #{result.detail}")
208
+ return result.status == "failed" ? CLI::Exit::FAILED : CLI::Exit::OK
209
+ end
210
+
211
+ @stdout.puts("#{id} #{@argv[0]}: ok")
212
+ CLI::Exit::OK
213
+ rescue Schedules::Usage => e
214
+ usage_error(e.message)
215
+ end
216
+
217
+ def failed(message)
218
+ error_line(message)
219
+ CLI::Exit::FAILED
220
+ end
221
+
222
+ def clock(iso)
223
+ return "-" if iso.nil? || iso.to_s.empty?
224
+
225
+ Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
226
+ rescue ArgumentError
227
+ iso.to_s
228
+ end
229
+
230
+ def entry_line(entry)
231
+ note = ""
232
+ note += " skips #{entry.skips} of #{entry.max_skips} (#{skip_window(entry)})" if entry.skips.positive?
233
+ note += " disabled after #{entry.failures} failures" unless entry.enabled
234
+ "#{entry.id} #{entry.mode} #{entry.recurrence_words} #{clock(entry.next_due)} #{entry.head[0, 8]}#{note}"
235
+ end
236
+
237
+ # A skip budget as a time window: 3 of 12 at a 5 minute tick is about 1 hour
238
+ # of patience, which is what the count really measures.
239
+ def skip_window(entry)
240
+ minutes = entry.max_skips * Schedules.tick_interval_minutes
241
+ hours = minutes / 60.0
242
+ hours >= 1 ? "≈#{(hours * 10).round / 10} h at a #{Schedules.tick_interval_minutes} min tick" : "≈#{minutes} min"
243
+ end
244
+
245
+ def entry_line_json(entry)
246
+ entry.to_h.transform_keys(&:to_s).merge(
247
+ "recurrence_words" => entry.recurrence_words,
248
+ "skip_window" => skip_window(entry)
249
+ )
250
+ end
251
+ end
252
+ end