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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "asset_state"
5
+ require_relative "file_actions"
4
6
  require_relative "file_digest"
5
7
  require_relative "manifest"
6
8
  require_relative "provenance"
@@ -20,8 +22,11 @@ module Samagotchi
20
22
  # warnings, and #run returns what the record (Provenance#write) keeps.
21
23
  # A dev install (+dev+, `chi bundle install --dev`) links each file to
22
24
  # the source instead of copying it, and warns of no checksum mismatch.
23
- # An edited file it replaces or removes is copied to the trash first,
24
- # under its subfolder (Trash#copy_edits); a link never is.
25
+ # Each folder is one loop over its files' AssetStates (#install_set):
26
+ # FileActions' asset tables say what each gets, the dry run reading the
27
+ # same row. An edited file it replaces or removes is copied to the trash
28
+ # first, under its subfolder (AssetState#edited?, Trash.edit?'s rule); a
29
+ # link never is.
25
30
  class AssetInstaller
26
31
  # What was installed: the paths Provenance#write records (each
27
32
  # empty / nil on a dry run).
@@ -32,6 +37,10 @@ module Samagotchi
32
37
  # The retire's summary words (Installer#summary): why the hooks went.
33
38
  RETIRED = "chi no longer loads class-file bundle hooks"
34
39
 
40
+ # A dry run's word for a real run's (Installer#summary): nothing would
41
+ # be written to a file already up to date.
42
+ DRY_WORDS = { installed: :would_install, updated: :would_update, up_to_date: :up_to_date }.freeze
43
+
35
44
  # @param source_dir [String] the normalized bundle source
36
45
  # @param manifest [Manifest, nil] nil for a non-strict install without one
37
46
  # @param existing [InstalledBundle, nil] the record before this install
@@ -65,9 +74,9 @@ module Samagotchi
65
74
  def run
66
75
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
67
76
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
68
- guardrail_files = install_guardrails(incoming_rules)
69
- plugin_file = install_plugin
70
- script_files = install_scripts
77
+ guardrail_files = install_set(:rule, rules_incoming(incoming_rules))
78
+ plugin_file = install_set(:plugin, plugin_incoming).values.first
79
+ script_files = install_set(:script, scripts_incoming)
71
80
  # After the plugin and scripts, which raise for a file the manifest
72
81
  # names and the bundle lacks: a refused install leaves hooks/ as it was.
73
82
  retire_hooks
@@ -95,8 +104,8 @@ module Samagotchi
95
104
  # load): every file in it goes, an edited one (or one the record
96
105
  # doesn't know) copied to the trash first, a dev link never (it points
97
106
  # at the source, which stays). Each is a :removed result (a dry run's
98
- # :would_remove, through #planned) with the RETIRED note; an empty
99
- # hooks/ dir goes without a word. The record keeps no hooks
107
+ # :would_remove) with the RETIRED note; an empty hooks/ dir goes
108
+ # without a word. The record keeps no hooks
100
109
  # (Provenance#write).
101
110
  def retire_hooks
102
111
  dir = @provenance.hooks_dir
@@ -104,7 +113,7 @@ module Samagotchi
104
113
 
105
114
  files = File.symlink?(dir) ? [] : Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.reject { |f| File.directory?(File.join(dir, f)) }
106
115
  files.each do |file|
107
- @results["hooks/#{file}"] = @dry_run ? planned(retired) : retired
116
+ @results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
108
117
  end
109
118
  return if @dry_run
110
119
 
@@ -114,44 +123,15 @@ module Samagotchi
114
123
 
115
124
  def retired = Installer::FileResult.new(word: :removed, note: RETIRED)
116
125
 
117
- # The bundle's guardrails/*.yml into <bundle_dir>/guardrails/: the
118
- # set replaces what an earlier version installed. Same bytes as
119
- # installed are reported up to date, not "Installed".
120
- # @return [Hash{String => String}] basename => installed path ({} on a dry run)
121
- def install_guardrails(incoming_rules)
122
- target = @provenance.guardrails_dir
123
- results = incoming_rules.to_h { |src| [src, result_for(src, File.join(target, File.basename(src)), updated: false)] }
124
- if @dry_run
125
- results.each { |src, result| @results["guardrails/#{File.basename(src)}"] = planned(result) }
126
- return {}
127
- end
128
-
129
- @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
130
- FileUtils.rm_rf(target)
131
- return {} if incoming_rules.empty?
126
+ # The bundle's guardrails/*.yml, by basename.
127
+ def rules_incoming(incoming_rules) = incoming_rules.to_h { |src| [File.basename(src), src] }
132
128
 
133
- FileUtils.mkdir_p(target)
134
- incoming_rules.to_h do |src|
135
- dest = File.join(target, File.basename(src))
136
- place(src, dest)
137
- @results["guardrails/#{File.basename(src)}"] = results[src]
138
- [File.basename(src), dest]
139
- end
140
- end
141
-
142
- # Copy the manifest's plugin file into the bundle's plugin/ dir,
143
- # warning when its sha256 differs from the declared one or this chi
144
- # doesn't meet requires_chi (the Engine then won't load it).
145
- # @return [String, nil] the installed file (nil: none, or a dry run)
146
- def install_plugin
129
+ # The manifest's plugin file, warning when this chi doesn't meet
130
+ # requires_chi (the Engine then won't load it). {} without one.
131
+ # @return [Hash{String => String}] its file => the source file
132
+ def plugin_incoming
147
133
  plugin = @manifest&.plugin
148
- unless plugin
149
- unless @dry_run
150
- @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
151
- FileUtils.rm_rf(@provenance.plugin_dir)
152
- end
153
- return nil
154
- end
134
+ return {} unless plugin
155
135
 
156
136
  src = File.join(@bundle_dir, plugin[:file])
157
137
  raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
@@ -160,83 +140,84 @@ module Samagotchi
160
140
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
161
141
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
162
142
  end
163
- dest = File.join(@provenance.plugin_dir, plugin[:file])
164
- result = result_for(src, dest, updated: @upgrade && @provenance.installed?)
165
- if @dry_run
166
- @results[plugin[:file]] = planned(result)
167
- return nil
168
- end
169
-
170
- @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
171
- FileUtils.rm_rf(@provenance.plugin_dir)
172
- FileUtils.mkdir_p(@provenance.plugin_dir)
173
- place(src, dest)
174
- @results[plugin[:file]] = result
175
- warning = FileDigest.mismatch("plugin #{plugin[:file]}", dest, @manifest.checksum_for_plugin) if @strict && !@dev
176
- @warnings << warning if warning
177
- dest
143
+ { plugin[:file] => src }
178
144
  end
179
145
 
180
- # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
181
- # folder replaced: a script the new version dropped goes). A declared
182
- # sha256 that differs warns, as a plugin's does.
183
- # @return [Hash{String => String}] file name => installed path
184
- def install_scripts
146
+ # The manifest's scripts (a dev install warns of one it can't run).
147
+ # @return [Hash{String => String}] file name => the source file
148
+ def scripts_incoming
185
149
  scripts = @manifest&.scripts || {}
186
- scripts.each_key do |file|
150
+ scripts.keys.to_h do |file|
187
151
  src = File.join(@bundle_dir, "scripts", file)
188
152
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
189
153
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
190
154
 
191
155
  @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
156
+ [file, src]
192
157
  end
193
- results = scripts.keys.to_h do |file|
194
- [file, result_for(File.join(@bundle_dir, "scripts", file), File.join(@provenance.scripts_dir, file),
195
- updated: @upgrade && @provenance.installed?)]
158
+ end
159
+
160
+ # One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
161
+ # replaced whole by +incoming+: each file's AssetState and its action
162
+ # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
163
+ # bundle), one result per incoming file (a dry run's DRY_WORDS of it,
164
+ # and nothing written), an edit copied to the trash first (save_first),
165
+ # then the folder replaced. A file the source dropped goes without a
166
+ # word. A plugin or script whose sha256 differs from the declared one
167
+ # warns (not a dev link: it is the source).
168
+ # @param incoming [Hash{String => String}] name => the source file, in
169
+ # the order the results list them
170
+ # @return [Hash{String => String}] name => installed path ({} on a dry run)
171
+ def install_set(kind_of_file, incoming)
172
+ dir, recorded = folder(kind_of_file)
173
+ command = @upgrade && @provenance.installed? ? :upgrade : :install
174
+ states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
175
+ .to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
176
+ results = incoming.keys.to_h do |name|
177
+ asset, action = states.fetch(name)
178
+ [name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
196
179
  end
197
180
  if @dry_run
198
- results.each { |file, result| @results["scripts/#{file}"] = planned(result) }
181
+ results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
199
182
  return {}
200
183
  end
201
184
 
202
- @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
203
- scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
204
- FileUtils.rm_rf(@provenance.scripts_dir)
205
- return {} if scripts.empty?
206
-
207
- FileUtils.mkdir_p(@provenance.scripts_dir)
208
- scripts.to_h do |file, declared|
209
- dest = File.join(@provenance.scripts_dir, file)
210
- place(File.join(@bundle_dir, "scripts", file), dest)
211
- @results["scripts/#{file}"] = results[file]
212
- FileUtils.chmod(0o755, dest) unless @dev
213
- warning = FileDigest.mismatch("script #{file}", dest, declared.to_s.delete_prefix("sha256:")) if @strict && !@dev
214
- @warnings << warning if warning
215
- [file, dest]
185
+ states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
186
+ FileUtils.rm_rf(dir)
187
+ return {} if incoming.empty?
188
+
189
+ FileUtils.mkdir_p(dir)
190
+ incoming.to_h do |name, src|
191
+ dest = File.join(dir, name)
192
+ place(src, dest)
193
+ @results[result_key(kind_of_file, name)] = results[name]
194
+ FileUtils.chmod(0o755, dest) if kind_of_file == :script && !@dev
195
+ if (declared = declared_sha(kind_of_file, name)) && results[name].action.do == :write_incoming && @strict && !@dev
196
+ warning = FileDigest.mismatch("#{kind_of_file} #{name}", dest, declared)
197
+ @warnings << warning if warning
198
+ end
199
+ [name, dest]
216
200
  end
217
201
  end
218
202
 
219
- # What the real run reports for +src+ over +dest+: up to date when the
220
- # bytes are the same, else updated (+updated+: an upgrade over an
221
- # install, of a file that is there) or installed. A dry run records #planned of it, so the two
222
- # can't drift.
223
- def result_for(src, dest, updated:)
224
- word = if File.file?(dest) && FileUtils.identical?(src, dest) then :up_to_date
225
- elsif updated && File.exist?(dest) then :updated
226
- else :installed
227
- end
228
- Installer::FileResult.new(word: word)
203
+ # The kind's folder and the record's shas for it.
204
+ def folder(kind_of_file)
205
+ case kind_of_file
206
+ when :rule then [@provenance.guardrails_dir, @existing&.guardrails]
207
+ when :plugin then [@provenance.plugin_dir, @existing&.plugin_shas]
208
+ else [@provenance.scripts_dir, @existing&.scripts]
209
+ end
229
210
  end
230
211
 
231
- # A dry run's word for a result: would_install, would_update,
232
- # would_remove, or the up to date result as it is (nothing would be
233
- # written).
234
- def planned(result)
235
- case result.word
236
- when :installed then result.with(word: :would_install)
237
- when :updated then result.with(word: :would_update)
238
- when :removed then result.with(word: :would_remove)
239
- else result
212
+ # Its key in the Installer's results (the summary's name): a rule's
213
+ # guardrails/r.yml, the plugin's own file name, a script's scripts/s.sh.
214
+ def result_key(kind_of_file, name) = kind_of_file == :plugin ? name : "#{AssetState::FOLDERS.fetch(kind_of_file)}/#{name}"
215
+
216
+ # The manifest's sha256 for a plugin or script (hex), nil for a rule.
217
+ def declared_sha(kind_of_file, name)
218
+ case kind_of_file
219
+ when :plugin then @manifest.checksum_for_plugin
220
+ when :script then @manifest.scripts[name].to_s.delete_prefix("sha256:")
240
221
  end
241
222
  end
242
223
 
@@ -267,7 +248,7 @@ module Samagotchi
267
248
  # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
268
249
  # requires_chi this chi doesn't meet, and a session that never loaded
269
250
  # them records a required load failure (the gate refuses guarded calls
270
- # until chi is updated): say so at install, as install_plugin does.
251
+ # until chi is updated): say so at install, as #plugin_incoming does.
271
252
  def warn_rules_requires_chi(rule_files)
272
253
  return if @manifest.nil? || rule_files.empty?
273
254
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "trash"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # One file of a bundle's own folder (a guardrail rule, the plugin, a
9
+ # script) as a command finds it: derived from the file on disk, the sha
10
+ # the record holds and the source file that would take its place, never
11
+ # stored. FileActions' ASSET_* tables say what each command does in each
12
+ # state; AssetInstaller, ShippedUpdate's plan, Uninstaller and Status read
13
+ # it. Whether it is edited is Trash.edit?'s answer (what the trash keeps a
14
+ # copy of), asked once in .of.
15
+ #
16
+ # name its path in its folder (r.yml, plugin.rb, s.sh)
17
+ # path where it is installed
18
+ # kind_of_file :rule, :plugin or :script
19
+ # state :new not there (or a broken link), the source has it
20
+ # :unchanged the source's bytes already (a dev link to them too)
21
+ # :pristine the recorded sha, the source has other bytes
22
+ # :edited neither the recorded sha (none recorded: unknown,
23
+ # so edited) nor the source's bytes
24
+ # :linked a link (a dev install's) to other bytes than the
25
+ # source's: it points at a source nothing here touches
26
+ # :dropped_pristine, :dropped_edited there, the source hasn't
27
+ # it (with no source read, as uninstall and status
28
+ # do, every file there is one of these)
29
+ # :missing recorded, not there, the source hasn't it
30
+ # recorded the record's sha256 for it (nil: not in the record)
31
+ # incoming the source file that replaces it (nil: none)
32
+ AssetState = Data.define(:name, :path, :kind_of_file, :state, :recorded, :incoming) do
33
+ # @param path [String] the installed file
34
+ # @param recorded [String, nil] its recorded sha256
35
+ # @param incoming [String, nil] the source file put in its place
36
+ def self.of(path, kind_of_file:, recorded: nil, incoming: nil, name: File.basename(path))
37
+ new(name: name, path: path, kind_of_file: kind_of_file, state: state_of(path, recorded, incoming),
38
+ recorded: recorded, incoming: incoming)
39
+ end
40
+
41
+ # Every file of the folder +dir+ a command meets: each file in it (at
42
+ # any depth, as Trash#copy_edits reads it; a linked dir isn't followed),
43
+ # each incoming one and each recorded one, by name.
44
+ # @param recorded [Hash, nil] name => recorded sha256
45
+ # @param incoming [Hash] name => the source file
46
+ # @return [Array<AssetState>]
47
+ def self.in(dir, kind_of_file:, recorded: nil, incoming: {})
48
+ recorded ||= {}
49
+ on_disk = if File.symlink?(dir) || !File.directory?(dir) then []
50
+ else Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).reject { |file| File.directory?(File.join(dir, file)) }
51
+ end
52
+ (on_disk | incoming.keys | recorded.keys).sort.map do |name|
53
+ of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], incoming: incoming[name], name: name)
54
+ end
55
+ end
56
+
57
+ def self.state_of(path, recorded, incoming)
58
+ if incoming && File.file?(path) && FileUtils.identical?(incoming, path) then :unchanged
59
+ elsif File.symlink?(path) && File.exist?(path) then :linked
60
+ elsif !File.file?(path) then incoming ? :new : :missing
61
+ elsif Trash.edit?(path, recorded: recorded, incoming: incoming) then incoming ? :edited : :dropped_edited
62
+ else incoming ? :pristine : :dropped_pristine
63
+ end
64
+ end
65
+ private_class_method :state_of
66
+
67
+ # Trash.edit?'s rule (asked in .of): an edit a replace or a removal
68
+ # would lose, so the trash keeps a copy first.
69
+ def edited? = %i[edited dropped_edited].include?(state)
70
+
71
+ # Where the trash keeps its copy, and the plan names it: guardrails/r.yml,
72
+ # plugin/plugin.rb, scripts/s.sh.
73
+ def label = "#{self.class::FOLDERS.fetch(kind_of_file)}/#{name}"
74
+ end
75
+
76
+ class AssetState
77
+ # Each kind's folder in the bundle's own dir (and in the trash).
78
+ FOLDERS = { rule: "guardrails", plugin: "plugin", script: "scripts" }.freeze
79
+ end
80
+ end
81
+ end
@@ -22,6 +22,8 @@ module Samagotchi
22
22
  # :keep left as it is (already the bundle's, or kept)
23
23
  # :skip left as it is, the bundle doesn't take it
24
24
  # :move_to_trash moved to the trash (a removal)
25
+ # :remove deleted (an asset: save_first copies an edit to the
26
+ # trash first)
25
27
  # :refuse the command stops
26
28
  # :restore the bundle's text put back (SystemBundle at its version)
27
29
  # save_first: a copy of the old bytes goes to the trash before a write
@@ -157,6 +159,55 @@ module Samagotchi
157
159
 
158
160
  TABLES = { install: INSTALL, force: FORCE, upgrade: UPGRADE, remerge: REMERGE, restore: RESTORE,
159
161
  uninstall: UNINSTALL }.freeze
162
+
163
+ # A bundle's own folder (rules, the plugin, scripts: AssetState), which
164
+ # every install replaces whole: the source's files go in, the rest go,
165
+ # an edit (AssetState#edited?) copied to the trash first. A cell is keyed
166
+ # [state, kind_of_file] or [state, :any]. A file the source has is
167
+ # written even when it has its bytes already (and so checked against the
168
+ # manifest's sha, as any other).
169
+ ASSET_WRITE = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk)
170
+ ASSET_REMOVE = Action.new(do: :remove, word: nil, next_base: :none, record: :drop)
171
+ ASSET_GONE = Action.new(do: :skip, word: nil, next_base: :none, record: :drop)
172
+
173
+ # A plain install, install --force, and an upgrade with no record yet.
174
+ ASSET_INSTALL = {
175
+ %i[new any] => ASSET_WRITE, %i[unchanged any] => ASSET_WRITE.with(word: :up_to_date),
176
+ %i[pristine any] => ASSET_WRITE, %i[edited any] => ASSET_WRITE.with(save_first: true),
177
+ %i[linked any] => ASSET_WRITE,
178
+ # The source dropped it: it goes with the folder, without a word.
179
+ %i[dropped_pristine any] => ASSET_REMOVE, %i[dropped_edited any] => ASSET_REMOVE.with(save_first: true),
180
+ %i[missing any] => ASSET_GONE
181
+ }.freeze
182
+
183
+ # An upgrade (--force too) over the record: a file it replaces is
184
+ # updated, except a rule (an upgrade lists a replaced rule as installed:
185
+ # the corpus's rule-installed).
186
+ ASSET_UPGRADE = ASSET_INSTALL.merge(
187
+ %i[pristine any] => ASSET_WRITE.with(word: :updated),
188
+ %i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
189
+ %i[linked any] => ASSET_WRITE.with(word: :updated),
190
+ %i[pristine rule] => ASSET_WRITE, %i[edited rule] => ASSET_WRITE.with(save_first: true),
191
+ %i[linked rule] => ASSET_WRITE
192
+ ).freeze
193
+
194
+ # Uninstall: the folder goes (no source, so every file there is a
195
+ # dropped_* or a link), an edit copied to the trash first, --force or not.
196
+ ASSET_UNINSTALL = {
197
+ %i[dropped_pristine any] => ASSET_REMOVE, %i[dropped_edited any] => ASSET_REMOVE.with(save_first: true),
198
+ %i[linked any] => ASSET_REMOVE, %i[missing any] => ASSET_GONE
199
+ }.freeze
200
+
201
+ ASSET_TABLES = { install: ASSET_INSTALL, upgrade: ASSET_UPGRADE, uninstall: ASSET_UNINSTALL }.freeze
202
+
203
+ # The command's action for an AssetState: [state, kind_of_file], then
204
+ # [state, :any].
205
+ # @param command [Symbol] an ASSET_TABLES key
206
+ # @return [Action, nil] nil: the command never meets the state
207
+ def self.for_asset(command, asset)
208
+ table = ASSET_TABLES.fetch(command)
209
+ table[[asset.state, asset.kind_of_file]] || table[[asset.state, :any]]
210
+ end
160
211
  # The kinds whose row depends on another bundle listing the file.
161
212
  SHARED_ASKED = %i[dropped_pristine dropped_edited pristine pristine_no_base edited edited_no_base].freeze
162
213
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "asset_installer"
4
+ require_relative "asset_state"
4
5
  require_relative "installer"
5
6
  require_relative "listing"
6
7
  require_relative "manifest"
@@ -28,10 +29,11 @@ module Samagotchi
28
29
  #
29
30
  # .md files take the Installer's 3-way merge: a clean text merge replaces
30
31
  # the file (merged), a real conflict keeps the user's (behind). Rules,
31
- # the plugin and scripts are replaced, an older chi's hooks removed; an edited one (Trash.edit?: its
32
- # bytes neither the recorded sha, none recorded counting as edited, nor
33
- # the incoming file's) is copied to the trash first and listed in
34
- # replaced (#trash_bound).
32
+ # the plugin and scripts are replaced, an older chi's hooks removed; an
33
+ # edited one (AssetState#edited?, Trash.edit?'s rule: its bytes neither
34
+ # the recorded sha, none recorded counting as edited, nor the incoming
35
+ # file's) is copied to the trash first and listed in replaced
36
+ # (#trash_bound).
35
37
  #
36
38
  # A profile (a shipped meta bundle) is updated through Profile: its row
37
39
  # is :would_update when the shipped version is newer or it has members
@@ -293,21 +295,24 @@ module Samagotchi
293
295
  end
294
296
 
295
297
  # What the upgrade from +source_dir+ copies to the trash, as
296
- # hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edits over the
297
- # folders AssetInstaller replaces or removes, each file against the one
298
- # it puts in its place. Rules, the plugin and scripts: their whole
299
- # folder, which the upgrade replaces. hooks/: an older chi's, which the
300
- # upgrade removes whole (AssetInstaller#retire_hooks; no incoming).
298
+ # hooks/F, guardrails/F, plugin/F, scripts/F. Rules, the plugin and
299
+ # scripts: each file of the folders AssetInstaller replaces, against the
300
+ # one it puts in its place, that is AssetState#edited? (the upgrade's
301
+ # save_first: the same files, the same rule). hooks/: an older chi's,
302
+ # which the upgrade removes whole (AssetInstaller#retire_hooks; no
303
+ # incoming): Trash.edits over it.
301
304
  def trash_bound(name, bundle, manifest, source_dir)
302
305
  provenance = Provenance.new(name: name)
303
306
  rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
304
307
  .reject { |file| AssetInstaller.linked?(file, source_dir) }
305
308
  plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
306
309
  scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
310
+ assets = AssetState.in(provenance.guardrails_dir, kind_of_file: :rule, recorded: bundle.guardrails,
311
+ incoming: rules.to_h { |file| [File.basename(file), file] }) +
312
+ AssetState.in(provenance.plugin_dir, kind_of_file: :plugin, recorded: bundle.plugin_shas, incoming: plugin) +
313
+ AssetState.in(provenance.scripts_dir, kind_of_file: :script, recorded: bundle.scripts, incoming: scripts)
307
314
  Trash.edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256)) +
308
- Trash.edits(provenance.guardrails_dir, "guardrails", bundle.guardrails, rules.to_h { |file| [File.basename(file), file] }) +
309
- Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
310
- Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
315
+ assets.select(&:edited?).map(&:label)
311
316
  end
312
317
 
313
318
  # Installed rules and the plugin whose sha differs from the recorded
@@ -3,6 +3,7 @@
3
3
  require_relative "file_digest"
4
4
  require_relative "provenance"
5
5
  require_relative "file_state"
6
+ require_relative "asset_state"
6
7
  require_relative "manifest"
7
8
  require_relative "../version"
8
9
  require_relative "index_updater"
@@ -23,8 +24,10 @@ module Samagotchi
23
24
  # paths at all). An entry's behind is its FileStanding::Behind, nil for a
24
25
  # file that isn't; merge_note its FileStanding::MergeNote, nil without one;
25
26
  # unreadable: the file is there, but this chi can't read it.
27
+ # rules and scripts: each recorded one by name, "ok", "modified" or
28
+ # "missing" (asset_states).
26
29
  Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
27
- :retired_hooks, :needs)
30
+ :rules, :scripts, :retired_hooks, :needs)
28
31
 
29
32
  # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
30
33
  # guessed: scope_error says so, target_dir is nil and each file is
@@ -70,6 +73,8 @@ module Samagotchi
70
73
  end
71
74
  Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
72
75
  target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
76
+ rules: asset_states(provenance.guardrails_dir, :rule, bundle.guardrails),
77
+ scripts: asset_states(provenance.scripts_dir, :script, bundle.scripts),
73
78
  retired_hooks: retired_hooks(provenance, bundle), needs: needs_status(bundle))
74
79
  end
75
80
 
@@ -88,6 +93,20 @@ module Samagotchi
88
93
  end
89
94
  end
90
95
 
96
+ # Each recorded rule or script: "missing" (not there), "modified" (its
97
+ # AssetState is edited: the next install replaces it, a copy kept in
98
+ # the trash; an edited rule doesn't load) or "ok"; {} for none.
99
+ # @return [Hash{String => String}]
100
+ def self.asset_states(dir, kind_of_file, recorded)
101
+ recorded.keys.sort.to_h do |name|
102
+ asset = AssetState.of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], name: name)
103
+ [name, if asset.state == :missing then "missing"
104
+ elsif asset.edited? then "modified"
105
+ else "ok"
106
+ end]
107
+ end
108
+ end
109
+
91
110
  # The stored needs, each with found: from this process's PATH (the
92
111
  # shell's; a worker's PATH can differ, see docs/memory.md). A needs:
93
112
  # that doesn't parse is none.
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "provenance"
7
+ require_relative "asset_state"
7
8
  require_relative "file_actions"
8
9
  require_relative "index_updater"
9
10
  require_relative "trash"
@@ -144,12 +145,17 @@ module Samagotchi
144
145
  private
145
146
 
146
147
  # Copies each edited file of the bundle's own folder to the trash: the
147
- # rm_rf below takes them, --force or not.
148
+ # rm_rf below takes them, --force or not. Rules, the plugin and
149
+ # scripts by their source-less AssetState and ASSET_UNINSTALL's
150
+ # save_first; an older chi's hooks/ by Trash#copy_edits.
148
151
  def save_edits(provenance, bundle)
149
152
  @trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
150
- @trash.copy_edits(provenance.guardrails_dir, "guardrails", bundle.guardrails)
151
- @trash.copy_edits(provenance.plugin_dir, "plugin", bundle.plugin_shas)
152
- @trash.copy_edits(provenance.scripts_dir, "scripts", bundle.scripts)
153
+ { rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
154
+ script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
155
+ AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded).each do |asset|
156
+ @trash.copy(asset.path, as: asset.label) if FileActions.for_asset(:uninstall, asset)&.save_first
157
+ end
158
+ end
153
159
  end
154
160
 
155
161
  # A kept shared file's index line names the bundle that still has it
@@ -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