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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
4
5
  require "fileutils"
5
6
  require "time"
@@ -13,7 +14,9 @@ module Samagotchi
13
14
  # written aside and renamed under an exclusive flock (several chi
14
15
  # processes share it). An entry relaxes an ask, never a deny:
15
16
  # session — this exact call (tool + normalized command / paths) in
16
- # this session;
17
+ # this session; a command's in its place too (the
18
+ # worktree or folder it ran in, and a digest of its env:),
19
+ # since the same text acts on whatever they point at;
17
20
  # repo — this exact call in this repo (the cwd outside a repo);
18
21
  # rule — anything this rule (from this source) asks about here.
19
22
  # "This repo" is the repository (repo: its common git dir), so an
@@ -74,11 +77,14 @@ module Samagotchi
74
77
  here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
75
78
  session_id = verdict.context&.session_id
76
79
  session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
80
+ command_place = session_place(verdict)
77
81
  list.find do |e|
78
82
  next false if session_only && e["scope"] != "session"
79
83
 
80
84
  case e["scope"]
81
- when "session" then session_id && e["session_id"] == session_id && e["key"] == key
85
+ when "session"
86
+ session_id && e["session_id"] == session_id && e["key"] == key &&
87
+ (command_place.nil? || e.slice("repo_root", "env") == command_place)
82
88
  when "repo" then here.call(e) && e["key"] == key
83
89
  when "rule"
84
90
  # One rule's grant can't answer for a second rule's ask on the call.
@@ -94,7 +100,9 @@ module Samagotchi
94
100
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
95
101
  "acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
96
102
  case scope
97
- when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
103
+ when "session"
104
+ entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
105
+ entry.merge!(session_place(verdict) || {})
98
106
  when "repo"
99
107
  entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
100
108
  when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
@@ -131,6 +139,22 @@ module Samagotchi
131
139
  t&.repo_root || t&.cwd || verdict.context&.cwd
132
140
  end
133
141
 
142
+ # Where a session entry of a command holds: the call's worktree root
143
+ # (or folder: #place_for; never the common git dir, which every
144
+ # worktree shares) and, when it sets one, a sha256 of its env: (never
145
+ # the env: it may hold a secret). nil for a call with no command:
146
+ # its paths are its place. An entry without them (stored before) no
147
+ # longer matches: one more ask.
148
+ # @return [Hash, nil]
149
+ def self.session_place(verdict)
150
+ t = verdict.targets
151
+ return nil unless t&.command
152
+
153
+ env = t.env.to_h
154
+ digest = env.empty? ? nil : Digest::SHA256.hexdigest(JSON.generate(env.map { |k, v| [k.to_s, v.to_s] }.sort))
155
+ { "repo_root" => place_for(verdict), "env" => digest }.compact
156
+ end
157
+
134
158
  private
135
159
 
136
160
  # @param locked [Boolean] the caller holds the store's lock
@@ -52,7 +52,7 @@ module Samagotchi
52
52
  RULE = "pair-boundary"
53
53
  SOURCE = "core"
54
54
  OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
55
- find_tools objectives].freeze
55
+ find_tools objectives schedule_task list_schedules cancel_schedule].freeze
56
56
  OWNER_VOICED_PREFIX = "delegate_"
57
57
  # The background task tools that read the owner's tasks (a pair turn
58
58
  # starts none: task_create is owner-voiced, and its execute is never
@@ -24,7 +24,6 @@ module Samagotchi
24
24
  # (a chi.on handler :log, or :deny for an on_error: :fail_closed one).
25
25
  HOOK_FAILURE_EFFECTS = {
26
26
  log: "it's skipped, and only the log has its next failures",
27
- skip: "it's skipped, and its next failures are silent",
28
27
  deny: "it denies every tool call it guards, and its next failures are in each denial's reason"
29
28
  }.freeze
30
29
 
@@ -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,9 +113,13 @@ 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
117
+ end
118
+ if @dry_run
119
+ edits = Trash.edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
120
+ @warnings << "Would copy to the trash first: #{edits.join(", ")}" unless edits.empty?
121
+ return
108
122
  end
109
- return if @dry_run
110
123
 
111
124
  @trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
112
125
  FileUtils.rm_rf(dir)
@@ -114,44 +127,15 @@ module Samagotchi
114
127
 
115
128
  def retired = Installer::FileResult.new(word: :removed, note: RETIRED)
116
129
 
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?
130
+ # The bundle's guardrails/*.yml, by basename.
131
+ def rules_incoming(incoming_rules) = incoming_rules.to_h { |src| [File.basename(src), src] }
132
132
 
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
133
+ # The manifest's plugin file, warning when this chi doesn't meet
134
+ # requires_chi (the Engine then won't load it). {} without one.
135
+ # @return [Hash{String => String}] its file => the source file
136
+ def plugin_incoming
147
137
  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
138
+ return {} unless plugin
155
139
 
156
140
  src = File.join(@bundle_dir, plugin[:file])
157
141
  raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
@@ -160,83 +144,101 @@ module Samagotchi
160
144
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
161
145
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
162
146
  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
147
+ { plugin[:file] => src }
178
148
  end
179
149
 
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
150
+ # The manifest's scripts (a dev install warns of one it can't run).
151
+ # @return [Hash{String => String}] file name => the source file
152
+ def scripts_incoming
185
153
  scripts = @manifest&.scripts || {}
186
- scripts.each_key do |file|
154
+ scripts.keys.to_h do |file|
187
155
  src = File.join(@bundle_dir, "scripts", file)
188
156
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
189
157
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
190
158
 
191
159
  @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
160
+ [file, src]
161
+ end
162
+ end
163
+
164
+ # One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
165
+ # replaced whole by +incoming+: each file's AssetState and its action
166
+ # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
167
+ # bundle), one result per incoming file (a dry run's DRY_WORDS of it,
168
+ # and nothing written), an edit copied to the trash first (save_first),
169
+ # then the folder replaced. A removed file is named in the summary.
170
+ # A plugin or script whose sha256 differs from the declared one warns
171
+ # (not a dev link: it is the source).
172
+ # @param incoming [Hash{String => String}] name => the source file, in
173
+ # the order the results list them
174
+ # @return [Hash{String => String}] name => installed path ({} on a dry run)
175
+ def install_set(kind_of_file, incoming)
176
+ dir, recorded = folder(kind_of_file)
177
+ command = @upgrade && @provenance.installed? ? :upgrade : :install
178
+ states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
179
+ .to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
180
+ if @dry_run
181
+ copies = states.filter_map { |_, (asset, action)| asset.label if action&.save_first }
182
+ @warnings << "Would copy to the trash first: #{copies.join(", ")}" unless copies.empty?
183
+ end
184
+ results = incoming.keys.to_h do |name|
185
+ asset, action = states.fetch(name)
186
+ [name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
192
187
  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?)]
188
+ dropped = states.filter_map do |name, (asset, action)|
189
+ next unless action&.do == :remove
190
+
191
+ [result_key(kind_of_file, name),
192
+ Installer::FileResult.new(word: @dry_run ? :would_remove : :removed, state: asset, action: action,
193
+ note: "no longer in the bundle")]
196
194
  end
197
195
  if @dry_run
198
- results.each { |file, result| @results["scripts/#{file}"] = planned(result) }
196
+ results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
197
+ dropped.each { |key, result| @results[key] = result }
199
198
  return {}
200
199
  end
201
200
 
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]
201
+ states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
202
+ FileUtils.rm_rf(dir)
203
+ if incoming.empty?
204
+ dropped.each { |key, result| @results[key] = result }
205
+ return {}
216
206
  end
207
+
208
+ FileUtils.mkdir_p(dir)
209
+ installed = incoming.to_h do |name, src|
210
+ dest = File.join(dir, name)
211
+ place(src, dest)
212
+ @results[result_key(kind_of_file, name)] = results[name]
213
+ FileUtils.chmod(0o755, dest) if kind_of_file == :script && !@dev
214
+ if (declared = declared_sha(kind_of_file, name)) && results[name].action.do == :write_incoming && @strict && !@dev
215
+ warning = FileDigest.mismatch("#{kind_of_file} #{name}", dest, declared)
216
+ @warnings << warning if warning
217
+ end
218
+ [name, dest]
219
+ end
220
+ dropped.each { |key, result| @results[key] = result }
221
+ installed
217
222
  end
218
223
 
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)
224
+ # The kind's folder and the record's shas for it.
225
+ def folder(kind_of_file)
226
+ case kind_of_file
227
+ when :rule then [@provenance.guardrails_dir, @existing&.guardrails]
228
+ when :plugin then [@provenance.plugin_dir, @existing&.plugin_shas]
229
+ else [@provenance.scripts_dir, @existing&.scripts]
230
+ end
229
231
  end
230
232
 
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
233
+ # Its key in the Installer's results (the summary's name): a rule's
234
+ # guardrails/r.yml, the plugin's own file name, a script's scripts/s.sh.
235
+ def result_key(kind_of_file, name) = kind_of_file == :plugin ? name : "#{AssetState::FOLDERS.fetch(kind_of_file)}/#{name}"
236
+
237
+ # The manifest's sha256 for a plugin or script (hex), nil for a rule.
238
+ def declared_sha(kind_of_file, name)
239
+ case kind_of_file
240
+ when :plugin then @manifest.checksum_for_plugin
241
+ when :script then @manifest.scripts[name].to_s.delete_prefix("sha256:")
240
242
  end
241
243
  end
242
244
 
@@ -267,7 +269,7 @@ module Samagotchi
267
269
  # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
268
270
  # requires_chi this chi doesn't meet, and a session that never loaded
269
271
  # them records a required load failure (the gate refuses guarded calls
270
- # until chi is updated): say so at install, as install_plugin does.
272
+ # until chi is updated): say so at install, as #plugin_incoming does.
271
273
  def warn_rules_requires_chi(rule_files)
272
274
  return if @manifest.nil? || rule_files.empty?
273
275
 
@@ -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
@@ -2,38 +2,18 @@
2
2
 
3
3
  module Samagotchi
4
4
  module MemoryBundle
5
- # One class-file hook's metadata, as an older chi's installed record
6
- # (manifest.json) keeps it (chi no longer installs or loads them:
7
- # Manifest::HooksRetired; it reads them to retire, uninstall and name
8
- # them, and the trash selection takes #sha256): the file's sha256
9
- # ("sha256:<hex>", "" when none is recorded), the events it registers
10
- # for (manifest `event:` for one, `events:` a list; none: it isn't
11
- # loaded), on_error (skip, log or fail_closed) and priority. The one
12
- # place the defaults live.
13
- class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
14
- DEFAULT_ON_ERROR = "skip"
15
- DEFAULT_PRIORITY = 100
5
+ # One class-file hook's sha256 in an older chi's installed record; its
6
+ # other fields are ignored now that class-file hooks are retired.
7
+ BundleHook = Data.define(:sha256) do
8
+ def initialize(sha256: "") = super
16
9
 
17
- # @param event [String, nil] one event (manifest `event:`)
18
- # @param events [Array<String>, String, nil] the events (manifest
19
- # `events:`); `event:` and `events:` together are joined, blanks
20
- # and repeats dropped
21
- def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
22
- names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
23
- super(sha256: sha256, events: names, on_error: on_error, priority: priority)
24
- end
25
-
26
- # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
27
- # symbol keys (a read record); missing or blank values get the
28
- # defaults
10
+ # @param raw [Hash, BundleHook, nil] string or symbol keys; legacy
11
+ # metadata beyond sha256 is ignored
29
12
  def self.parse(raw)
30
13
  return raw if raw.is_a?(BundleHook)
31
14
 
32
15
  h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
33
- on_error = h["on_error"].to_s.strip
34
- new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
35
- on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
36
- priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
16
+ new(sha256: prefixed(h["sha256"]))
37
17
  end
38
18
 
39
19
  # "sha256:<hex>" from either form, "" for none.
@@ -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,54 @@ 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 updated.
184
+ ASSET_UPGRADE = ASSET_INSTALL.merge(
185
+ %i[pristine any] => ASSET_WRITE.with(word: :updated),
186
+ %i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
187
+ %i[linked any] => ASSET_WRITE.with(word: :updated),
188
+ %i[pristine rule] => ASSET_WRITE.with(word: :updated),
189
+ %i[edited rule] => ASSET_WRITE.with(word: :updated, save_first: true),
190
+ %i[linked rule] => ASSET_WRITE
191
+ ).freeze
192
+
193
+ # Uninstall: the folder goes (no source, so every file there is a
194
+ # dropped_* or a link), an edit copied to the trash first, --force or not.
195
+ ASSET_UNINSTALL = {
196
+ %i[dropped_pristine any] => ASSET_REMOVE, %i[dropped_edited any] => ASSET_REMOVE.with(save_first: true),
197
+ %i[linked any] => ASSET_REMOVE, %i[missing any] => ASSET_GONE
198
+ }.freeze
199
+
200
+ ASSET_TABLES = { install: ASSET_INSTALL, upgrade: ASSET_UPGRADE, uninstall: ASSET_UNINSTALL }.freeze
201
+
202
+ # The command's action for an AssetState: [state, kind_of_file], then
203
+ # [state, :any].
204
+ # @param command [Symbol] an ASSET_TABLES key
205
+ # @return [Action, nil] nil: the command never meets the state
206
+ def self.for_asset(command, asset)
207
+ table = ASSET_TABLES.fetch(command)
208
+ table[[asset.state, asset.kind_of_file]] || table[[asset.state, :any]]
209
+ end
160
210
  # The kinds whose row depends on another bundle listing the file.
161
211
  SHARED_ASKED = %i[dropped_pristine dropped_edited pristine pristine_no_base edited edited_no_base].freeze
162
212
 
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # legacy_base: true on an older mark that has since got its incoming_sha (.legacy_mark?);
27
27
  # why, missed, missed_places: FileStanding::Behind's, nil on an older mark;
28
28
  # merged_version, merged_at, merged_trash: a merge no report has shown (Provenance#note_merged)
29
- # hooks basename => BundleHook
29
+ # hooks basename => BundleHook (sha256; other legacy fields ignored)
30
30
  # guardrails basename => recorded sha256 ("" when none)
31
31
  # scripts file name => recorded sha256 ("" when none)
32
32
  # plugin PluginRef, nil without one
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # Installs a bundle into a scoped memories directory.
21
21
  #
22
22
  # Flow (#run):
23
- # 1. Normalize source → directory, read manifest.yml (load_source)
23
+ # 1. Normalize source → directory, refuse retired hooks, read manifest.yml (load_source)
24
24
  # 2. Refuse a bundle whose requires_chi this chi doesn't meet,
25
25
  # unless force (refuse_too_new)
26
26
  # 3. Copy .md files to the target scope dir and update index.md
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  @bundle_dir = @source_dir = prepared.dir
357
357
  @source_owned = prepared.owned
358
358
  @source_commit = prepared.commit
359
- refuse_hooks
359
+ Manifest::HooksRetired.refuse_source_files(@bundle_dir)
360
360
  @manifest = Manifest.read(dir: @bundle_dir)
361
361
  clash = @manifest.session_settings_clash
362
362
  raise InstallError, clash if clash
@@ -371,15 +371,6 @@ module Samagotchi
371
371
  @warnings << "No manifest found — proceeding without strict manifest validation"
372
372
  end
373
373
 
374
- # A source that ships class-file hooks (hooks/*.rb, listed or not) is
375
- # refused before its manifest is read; its manifest's hooks: is
376
- # Manifest.read's refusal (Manifest::HooksRetired either way).
377
- # @raise [Manifest::HooksRetired]
378
- def refuse_hooks
379
- files = Manifest::HooksRetired.files_in(@bundle_dir)
380
- raise Manifest::HooksRetired.for_files(files) unless files.empty?
381
- end
382
-
383
374
  # A bundle whose requires_chi this chi doesn't meet is not installed at
384
375
  # all: --force is what installs it, and its plugin and rules then
385
376
  # warn that they won't load (AssetInstaller). The words come from
@@ -557,6 +548,7 @@ module Samagotchi
557
548
  # cell's text merge runs on temp copies). --force would install every
558
549
  # file, unread.
559
550
  def preview_memory(file_key, state, action)
551
+ preview_trash_copy(file_key, state, action)
560
552
  return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
561
553
  return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
562
554
 
@@ -564,9 +556,20 @@ module Samagotchi
564
556
  action = decision.action if decision
565
557
  return note_conflict(file_key, decision.outcome, state) if action.behind?
566
558
 
559
+ preview_trash_copy(file_key, state, action)
567
560
  @results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
568
561
  end
569
562
 
563
+ def preview_trash_copy(file_key, state, action)
564
+ return unless action.save_first
565
+
566
+ current = state.paths[:current]
567
+ incoming = state.paths[:incoming]
568
+ return unless File.file?(current) && !FileUtils.identical?(incoming, current)
569
+
570
+ @warnings << "Would copy to the trash first: #{file_key}"
571
+ end
572
+
570
573
  # Strict mode: a memory that takes the bundle's text (Action#takes_source?:
571
574
  # a new or missing file, --force, an upgrade's or a re-install's
572
575
  # fast-forward, a merge into a local edit) warns when the bundle's file