samagotchi 0.157.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 +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- 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/delegate.rb +12 -8
- 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/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- 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 +10 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
|
@@ -19,9 +19,10 @@ module Samagotchi
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
21
21
|
# The AGENTS.md files a session in +cwd+ loads, as absolute paths,
|
|
22
|
-
# outermost first: inside a
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# outermost first: inside a project every one from its top
|
|
23
|
+
# (MemoryPaths.project_top: a git work tree's, a linked worktree's own
|
|
24
|
+
# checkout, or a folder marked by `chi project init`) down to the cwd;
|
|
25
|
+
# outside a project only the cwd's ([] with none).
|
|
25
26
|
# @param cwd [String] where the prompt is built (Dir.pwd, not the
|
|
26
27
|
# session's working directory: /tmp vs /private/tmp would change the
|
|
27
28
|
# prompt bytes)
|
|
@@ -33,10 +34,10 @@ module Samagotchi
|
|
|
33
34
|
# The sections of the AGENTS.md chain (#agents_paths) with text: the
|
|
34
35
|
# first keeps the plain header (a one-file session's prompt is as it
|
|
35
36
|
# always was), each further one names its directory relative to the
|
|
36
|
-
#
|
|
37
|
+
# project's top ("Project specific description for pkg/:").
|
|
37
38
|
# @return [Array<Section>]
|
|
38
39
|
def agents_sections(cwd)
|
|
39
|
-
top = MemoryPaths.
|
|
40
|
+
top = MemoryPaths.project_top(cwd)
|
|
40
41
|
sections = []
|
|
41
42
|
agents_paths(cwd).each do |path|
|
|
42
43
|
dir = File.dirname(path)
|
|
@@ -47,10 +48,12 @@ module Samagotchi
|
|
|
47
48
|
sections
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
# The directories whose AGENTS.md a session in +cwd+ reads, outermost
|
|
51
|
+
# The directories whose AGENTS.md a session in +cwd+ reads, outermost
|
|
52
|
+
# first: from its project's top (the work tree's, or the marked root)
|
|
53
|
+
# down to +cwd+.
|
|
51
54
|
def chain_dirs(cwd)
|
|
52
55
|
cwd = File.expand_path(cwd)
|
|
53
|
-
top = MemoryPaths.
|
|
56
|
+
top = MemoryPaths.project_top(cwd)
|
|
54
57
|
return [cwd] unless top
|
|
55
58
|
|
|
56
59
|
rel = relative_dir(cwd, top)
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "steer"
|
|
4
|
-
require_relative "bridge/card_places"
|
|
5
4
|
|
|
6
5
|
module Samagotchi
|
|
7
6
|
# Where a saved recap sits among the turns a page draws: after the last
|
|
8
7
|
# turn opening it covers (Steer.turn_opening?, the rule its turns_since
|
|
9
8
|
# count goes by). Its stamp is that opening's id, as a card's is
|
|
10
|
-
# (
|
|
11
|
-
#
|
|
9
|
+
# (Steer.opening_stamp: a prompt's turn_id, a wake note's, a /continue no
|
|
10
|
+
# reason line's opening_id), so the page finds the bubble by
|
|
12
11
|
# id, not by counting back (a rollback or a check-in could make the count
|
|
13
12
|
# land a turn off). Read at read time from recap.json's `covered`: the file
|
|
14
13
|
# is unchanged, an old one gets a stamp too. Pure.
|
|
@@ -21,7 +20,7 @@ module Samagotchi
|
|
|
21
20
|
# messages; nil for none, or one with no id (a line an older chi wrote)
|
|
22
21
|
def opening_id(messages, covered)
|
|
23
22
|
opening = Array(messages).first([covered.to_i, 0].max).reverse_each.find { |message| Steer.turn_opening?(message) }
|
|
24
|
-
opening &&
|
|
23
|
+
opening && Steer.opening_stamp(opening)
|
|
25
24
|
end
|
|
26
25
|
end
|
|
27
26
|
end
|
|
@@ -213,6 +213,8 @@ module Samagotchi
|
|
|
213
213
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
214
214
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
215
215
|
chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
|
|
216
|
+
chi project [init [DIR]] this folder's project; init makes a folder in no git repository one
|
|
217
|
+
chi schedule <add|list|show|tick|run-now|enable|disable|cancel> durable schedules that outlive every worker
|
|
216
218
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
|
217
219
|
chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
218
220
|
chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
|
|
@@ -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
|