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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -6
- data/docs/cli.md +56 -1
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +3 -1
- data/docs/hooks.md +3 -0
- data/docs/internals/bridge.md +1 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/memory.md +5 -1
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +25 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command.rb +38 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
- data/lib/samagotchi/memory_bundle/status.rb +20 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- 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
|