samagotchi 0.158.0 → 0.160.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -7
  5. data/docs/cli.md +57 -2
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +24 -3
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/attached-context.md +4 -2
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +9 -7
  13. data/docs/internals/engine.md +7 -1
  14. data/docs/memory.md +46 -14
  15. data/docs/scheduling.md +241 -0
  16. data/docs/sessions.md +28 -2
  17. data/lib/samagotchi/bridge.rb +37 -4
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  19. data/lib/samagotchi/bundle_command.rb +39 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  22. data/lib/samagotchi/client_id.rb +17 -3
  23. data/lib/samagotchi/command_intake.rb +56 -0
  24. data/lib/samagotchi/config.rb +12 -0
  25. data/lib/samagotchi/context_absorber.rb +6 -3
  26. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  27. data/lib/samagotchi/context_candidates.rb +11 -8
  28. data/lib/samagotchi/context_command.rb +9 -3
  29. data/lib/samagotchi/context_move.rb +74 -0
  30. data/lib/samagotchi/context_poller.rb +5 -2
  31. data/lib/samagotchi/context_sources.rb +6 -4
  32. data/lib/samagotchi/engine/events.rb +13 -2
  33. data/lib/samagotchi/engine/guardrails.rb +3 -4
  34. data/lib/samagotchi/engine.rb +1 -0
  35. data/lib/samagotchi/engine_peers.rb +1 -0
  36. data/lib/samagotchi/events.rb +71 -0
  37. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  38. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  39. data/lib/samagotchi/hook_routes.rb +0 -1
  40. data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
  41. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  42. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  43. data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  46. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  47. data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
  48. data/lib/samagotchi/memory_bundle/status.rb +22 -3
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
  50. data/lib/samagotchi/memory_orphans.rb +14 -4
  51. data/lib/samagotchi/memory_paths.rb +29 -6
  52. data/lib/samagotchi/memory_usage.rb +23 -12
  53. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  54. data/lib/samagotchi/plugin/sessions.rb +4 -1
  55. data/lib/samagotchi/project_command.rb +58 -20
  56. data/lib/samagotchi/project_id.rb +102 -0
  57. data/lib/samagotchi/project_id_clash.rb +62 -0
  58. data/lib/samagotchi/project_init.rb +214 -10
  59. data/lib/samagotchi/project_scope.rb +11 -2
  60. data/lib/samagotchi/run_options.rb +1 -0
  61. data/lib/samagotchi/schedule_command.rb +252 -0
  62. data/lib/samagotchi/schedules.rb +1008 -0
  63. data/lib/samagotchi/session.rb +17 -2
  64. data/lib/samagotchi/session_access/contract.rb +1 -1
  65. data/lib/samagotchi/session_access/local.rb +28 -5
  66. data/lib/samagotchi/session_access/remote.rb +2 -1
  67. data/lib/samagotchi/session_access.rb +4 -1
  68. data/lib/samagotchi/session_commands.rb +7 -3
  69. data/lib/samagotchi/session_continue.rb +10 -6
  70. data/lib/samagotchi/session_manager/spawn.rb +34 -13
  71. data/lib/samagotchi/session_manager/worker_command.rb +11 -23
  72. data/lib/samagotchi/state_format.rb +118 -0
  73. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  74. data/lib/samagotchi/tool_declarations.rb +43 -0
  75. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  76. data/lib/samagotchi/tools/builtins.rb +17 -0
  77. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  78. data/lib/samagotchi/tools/context_read.rb +7 -2
  79. data/lib/samagotchi/tools/delegate.rb +10 -2
  80. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  81. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  82. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  83. data/lib/samagotchi/update_command.rb +2 -2
  84. data/lib/samagotchi/version.rb +1 -1
  85. data/lib/samagotchi/web/app.rb +5 -4
  86. data/lib/samagotchi/web/public/chains.js +32 -8
  87. data/lib/samagotchi/web/public/index.html +4 -1
  88. data/lib/samagotchi/web/public/info_bar.js +4 -3
  89. data/lib/samagotchi/web/public/turn_events.js +4 -0
  90. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  91. data/lib/samagotchi/web/session_hub.rb +20 -0
  92. data/lib/samagotchi/worker.rb +42 -12
  93. data/lib/samagotchi/worker_boot.rb +3 -4
  94. data/lib/samagotchi/worker_inbound.rb +44 -2
  95. data/lib/samagotchi/worker_link.rb +5 -0
  96. data/lib/samagotchi/worker_wakes.rb +9 -1
  97. metadata +12 -1
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "atomic_file"
4
+ require_relative "memory_paths"
5
+ require_relative "paths"
6
+
7
+ module Samagotchi
8
+ # Two folders with one chi project id: a marked folder copied (cp -R)
9
+ # keeps its marker, so the copy shares the original's memories. Told at
10
+ # session start (Engine#announce_load_events!, label "memory", beside
11
+ # MemoryOrphans), at every session start while it lasts.
12
+ #
13
+ # The claim: an id-keyed memories folder's `.root` names the folder that
14
+ # last started a session with it (one line; a dotfile, so no *.md glob
15
+ # lists it). Written only here, at session start, never by the key
16
+ # resolver: a read-only command or a memory call must not write.
17
+ ProjectIdClash = Data.define(:root, :other, :id) do
18
+ # The one text for the condition.
19
+ def message
20
+ "#{Paths.tilde(root)} and #{Paths.tilde(other)} share chi project id #{id} (a copied folder?): they share " \
21
+ "memories; `chi project init --new-id` gives this one its own."
22
+ end
23
+ end
24
+
25
+ class ProjectIdClash
26
+ CLAIM = ".root"
27
+
28
+ # +cwd+'s project, when a marked one with an id: a clash when the claim
29
+ # names another folder that still has a marker with this id; otherwise
30
+ # the claim is set to this root (when it changes; a moved folder just
31
+ # takes it over). Only once the id folder exists: nothing is made for a
32
+ # project with no memories yet.
33
+ # @return [ProjectIdClash, nil]
34
+ def self.check(env: ENV, cwd: Dir.pwd, home: MemoryPaths.default_home)
35
+ found = MemoryPaths.lookup(cwd, home: home)
36
+ return nil unless found.id
37
+
38
+ dir = File.join(MemoryPaths.projects_dir(env: env), found.id)
39
+ return nil unless File.directory?(dir)
40
+
41
+ claim = File.join(dir, CLAIM)
42
+ other = File.file?(claim) ? File.read(claim, 4096).to_s.lines.first.to_s.strip : ""
43
+ return nil if other == found.root
44
+ return new(root: found.root, other: other, id: found.id) if same_project?(other, found.id, home)
45
+
46
+ AtomicFile.write(claim, "#{found.root}\n")
47
+ nil
48
+ rescue SystemCallError
49
+ nil
50
+ end
51
+
52
+ # Whether +other+ still is a marked folder with +id+ (not gone, not
53
+ # given another id).
54
+ def self.same_project?(other, id, home)
55
+ return false if other.empty? || !File.directory?(other)
56
+
57
+ found = MemoryPaths.lookup(other, home: home)
58
+ found.marker? && found.root == File.realpath(other) && found.id == id
59
+ end
60
+ private_class_method :same_project?
61
+ end
62
+ end
@@ -1,20 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "atomic_file"
5
+ require_relative "context_sources"
4
6
  require_relative "guardrails/path_resolve"
7
+ require_relative "memory_orphans"
5
8
  require_relative "memory_paths"
6
9
  require_relative "paths"
10
+ require_relative "project_id"
11
+ require_relative "session_paths"
7
12
 
8
13
  module Samagotchi
9
- # The shared decisions for `chi project init` and chi web's project picker.
14
+ # The shared decisions for `chi project init` and chi web's project picker,
15
+ # and every text they refuse or report with (the CLI prints them). A new
16
+ # marker gets an id (ProjectId); #adopt gives an older marker one, #new_id
17
+ # a copied folder its own.
10
18
  module ProjectInit
11
- Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
19
+ # +id+: the project's id (ProjectId), nil for none (a git repository, a
20
+ # marker without one, a refusal).
21
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message, :id) do
22
+ def initialize(kind:, reason:, root:, top:, nested_in:, message:, id: nil) = super
23
+ end
24
+
25
+ # A folder keyed by the project key (MemoryPaths.key_for): under +base+,
26
+ # named +prefix+ + the key.
27
+ Place = Data.define(:base, :prefix) do
28
+ def path(key) = File.join(base, "#{prefix}#{key}")
29
+ end
12
30
 
13
31
  MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
32
 
15
33
  module_function
16
34
 
17
- def call(dir, home:)
35
+ # Marks +dir+ (a new marker gets an id), or says why not. A folder whose
36
+ # path key already names a folder (memories kept while it was no
37
+ # project, keyed by the same path) is marked without an id, so nothing
38
+ # it has goes out of reach: #adopt moves those and writes the id.
39
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
40
+ def call(dir, home:, env: ENV, state_dir: nil)
18
41
  dir = File.expand_path(dir.to_s)
19
42
  found = MemoryPaths.lookup(dir, home: home)
20
43
  if found.git?
@@ -32,19 +55,94 @@ module Samagotchi
32
55
  # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
56
  enclosing = found.marker? ? found.root : nil
34
57
  if enclosing == Guardrails::PathResolve.real(dir)
58
+ id_part = found.id ? " (id #{found.id})" : ""
35
59
  return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
36
- message: "#{Paths.tilde(enclosing)} is already a chi project")
60
+ message: "#{Paths.tilde(enclosing)} is already a chi project#{id_part}", id: found.id)
37
61
  end
38
62
 
39
- write_marker(dir)
63
+ id = write_marker(dir, places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)))
40
64
  root = File.realpath(dir)
41
65
  # No message: each surface words its own success (the CLI's report, the web's navigation).
42
- Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
66
+ Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil, id: id)
43
67
  rescue SystemCallError => e
44
68
  marker = File.join(dir, MemoryPaths::MARKER)
45
69
  write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
70
  end
47
71
 
72
+ # `chi project init` (the CLI only: chi web's New-project row calls
73
+ # #call, which never moves folders): #call, and for a marked folder
74
+ # with no valid id, its adoption of one. The folders keyed by its path
75
+ # key (its memories, its project context sources, the skills bundle's
76
+ # kept versions) move to the id, each leaving a link by its old name so
77
+ # an older chi, keying by path, finds the same folder; then the id is
78
+ # written (a crash before that leaves the path key reaching the moved
79
+ # folders through the links, and a rerun finishes with the same id).
80
+ # Refused while a running session belongs to the project: its worker
81
+ # could write a memory under the old name between a move and its link.
82
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
83
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+, +project+, +id+)
84
+ # A folder #call marked without an id is adopted the same way.
85
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+,
86
+ # +project+, +cwd+, +id+): a running one in the project (by the root it
87
+ # stored, else its folder's) refuses
88
+ # @return [Outcome] :adopted (its message the report), :created (its
89
+ # message what moved, nil when nothing did) or #call's
90
+ def adopt(dir, home:, env: ENV, state_dir: nil, sessions: [])
91
+ state_dir ||= SessionPaths.default_state_dir(env: env)
92
+ outcome = call(dir, home: home, env: env, state_dir: state_dir)
93
+ return outcome unless %i[already_marked created].include?(outcome.kind) && outcome.id.nil?
94
+
95
+ root = outcome.root
96
+ running = Array(sessions).select do |row|
97
+ row.live && (row.project ? row.project == root : MemoryPaths.lookup(row.cwd.to_s, home: home).root == root)
98
+ end
99
+ unless running.empty?
100
+ return refused(:running, root, "#{Paths.tilde(root)} has running sessions " \
101
+ "(#{running.map { |row| row.id[0, 8] }.join(", ")}): stop them " \
102
+ "(chi sessions stop ID) and rerun chi project init, which moves the " \
103
+ "project's folders while no session can write to them")
104
+ end
105
+
106
+ adopted = adopt_id(root, env: env, state_dir: state_dir)
107
+ return adopted unless outcome.kind == :created && adopted.kind == :adopted
108
+
109
+ # A new project: the CLI words its success; the report keeps what moved.
110
+ moved = adopted.message.lines.drop(1).join.chomp
111
+ outcome.with(id: adopted.id, message: moved.empty? ? nil : moved)
112
+ rescue SystemCallError => e
113
+ write_failed("couldn't adopt an id for #{Paths.tilde(outcome&.root || dir.to_s)}: #{e.message}")
114
+ end
115
+
116
+ # `chi project init --new-id`: a fresh id for a marked folder (a copied
117
+ # one, going its own way), its folders copied from the key it had to the
118
+ # new id (the copy keeps what it had; the other folder keeps its own).
119
+ # @return [Outcome] :new_id (its message the report) or :refused
120
+ def new_id(dir, home:, env: ENV, state_dir: nil)
121
+ dir = File.expand_path(dir.to_s)
122
+ found = MemoryPaths.lookup(dir, home: home)
123
+ unless found.marker? && found.root == Guardrails::PathResolve.real(dir)
124
+ return refused(:not_marked, nil, "#{Paths.tilde(dir)} is not a marked chi project folder: --new-id gives " \
125
+ "one (made with chi project init) an id of its own")
126
+ end
127
+
128
+ root = found.root
129
+ old_key = MemoryPaths.key_for(root: root, id: found.id)
130
+ id = ProjectId.generate(File.basename(root))
131
+ lines = ["#{Paths.tilde(root)} has a chi project id of its own now: #{id} (was #{found.id || "none: keyed by its path"})"]
132
+ places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)).each do |place|
133
+ from = place.path(old_key)
134
+ next unless File.directory?(from)
135
+
136
+ to = place.path(id)
137
+ copy_folder(File.realpath(from), to)
138
+ lines << "copied #{Paths.tilde(from)} to #{Paths.tilde(to)}"
139
+ end
140
+ write_id(root, id)
141
+ Outcome.new(kind: :new_id, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
142
+ rescue SystemCallError => e
143
+ write_failed("couldn't give #{Paths.tilde(dir)} a new id: #{e.message}")
144
+ end
145
+
48
146
  # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
147
  # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
148
  def ensure_folder(dir)
@@ -54,16 +152,122 @@ module Samagotchi
54
152
  write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
153
  end
56
154
 
155
+ # The folders a project key names: its memories, its project context
156
+ # sources (where the workers keep them: ContextSources.root), and the
157
+ # skills bundle's kept versions of its project skills (history/
158
+ # project-<key>, the bundle's own data dir).
159
+ # @return [Array<Place>]
160
+ def places(env:, state_dir:)
161
+ [Place.new(base: MemoryPaths.projects_dir(env: env), prefix: ""),
162
+ Place.new(base: File.join(ContextSources.root(state_dir: state_dir), "projects"), prefix: ""),
163
+ Place.new(base: File.join(File.dirname(state_dir), "plugins", "skills", "history"), prefix: "project-")]
164
+ end
165
+
166
+ def adopt_id(root, env:, state_dir:)
167
+ problem = ProjectId.read(root).problem
168
+ path_key = MemoryPaths.key_for_root(root)
169
+ # Before the id: what MemoryOrphans finds for the project keyed by its path.
170
+ orphans = MemoryOrphans.find(env: env, cwd: root)
171
+ list = places(env: env, state_dir: state_dir)
172
+ id = resumed_id(list.first, path_key) || ProjectId.generate(File.basename(root))
173
+ taken = list.map { |place| [place.path(path_key), place.path(id)] }
174
+ .find { |from, to| real_dir?(from) && (File.exist?(to) || File.symlink?(to)) }
175
+ if taken
176
+ return refused(:id_taken, root, "#{Paths.tilde(taken[1])} already exists: chi project init can't move " \
177
+ "#{Paths.tilde(taken[0])} there; nothing moved")
178
+ end
179
+
180
+ lines = ["#{Paths.tilde(root)} now has the chi project id #{id}: its memories and project context stay " \
181
+ "with it when the folder moves or is renamed"]
182
+ lines << "#{problem}: replaced" if problem
183
+ list.each { |place| lines.concat(move_folder(place.path(path_key), place.path(id))) }
184
+ write_id(root, id)
185
+ if orphans
186
+ lines << "memories from an earlier path: #{orphans.candidates.map { |c| Paths.tilde(c) }.join(", ")} (this " \
187
+ "folder's name with another path's hash; if they are this project's, move their files into " \
188
+ "#{Paths.tilde(list.first.path(id))})"
189
+ end
190
+ Outcome.new(kind: :adopted, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
191
+ end
192
+
193
+ # The id an adoption that crashed before writing it chose: the path
194
+ # key already a link to an id folder beside it.
195
+ def resumed_id(place, path_key)
196
+ link = place.path(path_key)
197
+ return nil unless File.symlink?(link)
198
+
199
+ target = File.readlink(link)
200
+ target if target.match?(ProjectId::PATTERN) && File.directory?(place.path(target))
201
+ end
202
+
203
+ def real_dir?(path) = File.directory?(path) && !File.symlink?(path)
204
+
205
+ # Move +from+ to +to+ and leave a link named +from+ (relative: the
206
+ # folder may move with its parent). Nothing to move (no folder, or a
207
+ # link already): no move and no link. A folder made again at +from+
208
+ # between the move and the link has its files moved over (none
209
+ # replaced), then gives way to the link; what was in both stays.
210
+ # @return [Array<String>] the report's lines
211
+ def move_folder(from, to)
212
+ return [] unless real_dir?(from)
213
+
214
+ File.rename(from, to)
215
+ begin
216
+ File.symlink(File.basename(to), from)
217
+ rescue Errno::EEXIST
218
+ Dir.children(from).each do |name|
219
+ File.rename(File.join(from, name), File.join(to, name)) unless File.exist?(File.join(to, name))
220
+ end
221
+ unless Dir.empty?(from)
222
+ return ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)}; files written to the old folder meanwhile " \
223
+ "that #{Paths.tilde(to)} has too stay in #{Paths.tilde(from)}"]
224
+ end
225
+
226
+ Dir.rmdir(from)
227
+ File.symlink(File.basename(to), from)
228
+ end
229
+ ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)} (the old name links to it, for an older chi)"]
230
+ end
231
+
232
+ # +to+ made a copy of +from+'s files, without the copied project's
233
+ # claim (.root: ProjectIdClash).
234
+ def copy_folder(from, to)
235
+ FileUtils.mkdir_p(to)
236
+ FileUtils.cp_r(File.join(from, "."), to)
237
+ FileUtils.rm_f(File.join(to, ".root"))
238
+ end
239
+
240
+ def write_id(root, id)
241
+ marker = File.join(root, MemoryPaths::MARKER)
242
+ AtomicFile.write(marker, ProjectId.with_id(File.read(marker), id))
243
+ end
244
+
245
+ def refused(reason, root, message)
246
+ Outcome.new(kind: :refused, reason: reason, root: root, top: root, nested_in: nil, message: message)
247
+ end
248
+
57
249
  def write_failed(message)
58
250
  Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
251
  end
60
- private_class_method :write_failed
61
252
 
62
- def write_marker(dir)
253
+ # @param places [Array<Place>] the folders a key names (#places)
254
+ # @return [String, nil] the new marker's id; nil when a folder already
255
+ # has the path key (#call)
256
+ def write_marker(dir, places)
63
257
  marker = File.join(dir, MemoryPaths::MARKER)
64
258
  FileUtils.mkdir_p(File.dirname(marker))
65
- File.write(marker, MARKER_TEXT)
259
+ root = File.realpath(dir)
260
+ key = MemoryPaths.key_for_root(root)
261
+ if places.any? { |place| File.exist?(place.path(key)) || File.symlink?(place.path(key)) }
262
+ File.write(marker, MARKER_TEXT)
263
+ return nil
264
+ end
265
+
266
+ id = ProjectId.generate(File.basename(root))
267
+ File.write(marker, ProjectId.with_id(MARKER_TEXT, id))
268
+ id
66
269
  end
67
- private_class_method :write_marker
270
+ private_class_method :write_failed, :write_marker, :refused, :adopt_id, :resumed_id, :real_dir?, :move_folder,
271
+ :copy_folder, :write_id
68
272
  end
69
273
  end
@@ -18,10 +18,19 @@ module Samagotchi
18
18
  return nil if dir.empty?
19
19
  return cache[dir] if cache&.key?(dir)
20
20
 
21
- found = MemoryPaths.lookup(File.expand_path(dir))
22
- root = found.project? ? found.root : nil
21
+ root = project_for(dir)&.root
23
22
  cache[dir] = root if cache
24
23
  root
25
24
  end
25
+
26
+ # +dir+'s project (MemoryPaths::ProjectLookup: its root and, for a
27
+ # marked project, its id), or nil in none.
28
+ def project_for(dir)
29
+ dir = dir.to_s
30
+ return nil if dir.empty?
31
+
32
+ found = MemoryPaths.lookup(File.expand_path(dir))
33
+ found.project? ? found : nil
34
+ end
26
35
  end
27
36
  end
@@ -214,6 +214,7 @@ module Samagotchi
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
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
217
218
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
218
219
  chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
219
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