samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -5,7 +5,6 @@ require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
7
  require_relative "../version"
8
- require_relative "bundle_hook"
9
8
 
10
9
  module Samagotchi
11
10
  autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
@@ -26,6 +25,8 @@ module Samagotchi
26
25
  # plugin: # optional — the bundle's plugin.rb (docs/plugins.md)
27
26
  # file: plugin.rb
28
27
  # sha256: sha256:0a1b...
28
+ # required: true # optional: a guardrail; when it fails to load
29
+ # # chi denies every tool call (docs/hooks.md)
29
30
  # requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
30
31
  # requires_chi_next: true # optional, shipped bundles — ignored here:
31
32
  # # `rake release:bump` raises requires_chi to
@@ -64,6 +65,26 @@ module Samagotchi
64
65
  class Manifest
65
66
  class ValidationError < StandardError; end
66
67
 
68
+ # Class-file bundle hooks (hooks/*.rb, the manifest's hooks:) are
69
+ # retired: chi installs and loads none. WORDS is the one wording of
70
+ # it, wherever chi meets one (the Engine's first-turn line, install,
71
+ # chi bundle check and status, chi update's row). A strict read of a
72
+ # manifest with a hooks: entry raises it (.for_key's message); a
73
+ # tolerant read (a kept manifest.yml) ignores the key.
74
+ class HooksRetired < ValidationError
75
+ WORDS = "class-file bundle hooks are retired: chi loads no hooks/*.rb; a bundle hooks events from its " \
76
+ "plugin.rb with chi.on, see docs/plugins.md"
77
+
78
+ # The manifest's hooks: key.
79
+ def self.for_key = new("hooks: #{WORDS}")
80
+
81
+ # A source's hooks/*.rb (+files+, relative), with or without the key.
82
+ def self.for_files(files) = new("#{files.join(", ")}: #{WORDS}")
83
+
84
+ # The source's hooks/*.rb, relative and sorted ([] for none).
85
+ def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
86
+ end
87
+
67
88
  # A plugin file is a plain .rb name in the bundle's top directory.
68
89
  PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
69
90
  # A script is a plain file name in the bundle's scripts/.
@@ -86,7 +107,7 @@ module Samagotchi
86
107
  # admits a chi that never saw the key. A key the first tag (v0.2.0)
87
108
  # already reads has no entry. `files.description` is a files: entry given
88
109
  # as a mapping, `*.types` a session_settings:/settings: given as a mapping
89
- # of keys to types, `hooks.events` a hook entry with an events: list. A key
110
+ # of keys to types. A key
90
111
  # whose release is not out yet waits here as a comment naming it, the way
91
112
  # Plugin::Api::SINCE does, until `release:bump` gives it its version
92
113
  # (docs/releasing.md).
@@ -98,15 +119,14 @@ module Samagotchi
98
119
  "session_settings" => "0.84.0",
99
120
  "session_settings.types" => "0.92.0",
100
121
  "settings.types" => "0.92.0",
101
- "hooks.events" => "0.98.0",
102
122
  "settings" => "0.143.0",
103
- "block_renderers" => "0.151.0"
123
+ "block_renderers" => "0.151.0",
124
+ "plugin.required" => "0.157.0"
104
125
  }.freeze
105
126
 
106
127
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
107
- # the description a mapping gave, for the files that have one; hooks:
108
- # name → BundleHook.
109
- attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
128
+ # the description a mapping gave, for the files that have one.
129
+ attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :trust_level, :plugin, :requires_chi, :needs, :includes,
110
130
  :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types, :unknown_keys
111
131
 
112
132
  def initialize(path:)
@@ -128,7 +148,8 @@ module Samagotchi
128
148
  @scope = normalize_scope(raw["scope"])
129
149
  @description = (raw["description"] || "").to_s
130
150
  @files = parse_files(raw["files"] || {})
131
- @hooks = parse_hooks(raw["hooks"] || {})
151
+ raise HooksRetired.for_key if self.class.lists_hooks?(raw["hooks"])
152
+
132
153
  @file_descriptions = parse_file_descriptions(raw["files"])
133
154
  @trust_level = (raw["trust_level"] || "experimental").to_s
134
155
  @plugin = parse_plugin(raw["plugin"])
@@ -167,8 +188,8 @@ module Samagotchi
167
188
  # chi (a newer chi tightened it, or an older one wrote it) costs only
168
189
  # that key, which reads as empty with its message in the problems:
169
190
  # name, version (never checked: identity is the record's), scope;
170
- # files and their descriptions, scripts, plugin; hooks (one bad entry
171
- # fails the whole key); needs, context_providers; block_renderers
191
+ # files and their descriptions, scripts, plugin (hooks: is ignored,
192
+ # HooksRetired); needs, context_providers; block_renderers
172
193
  # (against +fallback_scripts+ when scripts: failed); includes (after
173
194
  # what its check reads); session_settings with its types, settings
174
195
  # with theirs (one raw value each, so one problem); requires_chi,
@@ -183,6 +204,10 @@ module Samagotchi
183
204
  [manifest, manifest.send(:read_tolerantly, path, fallback_scripts)]
184
205
  end
185
206
 
207
+ # Whether a manifest's hooks: value lists anything (none, {} and []
208
+ # don't: a manifest may keep an empty key).
209
+ def self.lists_hooks?(raw) = !(raw.nil? || ((raw.is_a?(Hash) || raw.is_a?(Array)) && raw.empty?))
210
+
186
211
  def self.read(dir:)
187
212
  manifest_path = File.join(dir, "manifest.yml")
188
213
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
@@ -201,13 +226,6 @@ module Samagotchi
201
226
  hex unless hex.nil? || hex.empty?
202
227
  end
203
228
 
204
- # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
205
- # when it declares none (nothing to verify).
206
- def checksum_for_hook(basename)
207
- hex = @hooks[basename]&.hex
208
- hex unless hex.nil? || hex.empty?
209
- end
210
-
211
229
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
212
230
  def checksum_for_plugin
213
231
  sha = @plugin && @plugin[:sha256].to_s
@@ -239,8 +257,8 @@ module Samagotchi
239
257
 
240
258
  # The SINCE keys this manifest actually uses, each with the chi release
241
259
  # that first read it: the plain keys, and the shapes only a newer chi
242
- # reads — a files: entry given as a mapping, a hook entry with an events:
243
- # list, a session_settings:/settings: given as a mapping of keys to types.
260
+ # reads — a files: entry given as a mapping, a session_settings:/settings:
261
+ # given as a mapping of keys to types.
244
262
  # @return [Hash{String => String}]
245
263
  def floor_keys
246
264
  raw = @raw
@@ -249,20 +267,18 @@ module Samagotchi
249
267
  found["scripts"] = SINCE["scripts"] unless @scripts.empty?
250
268
  found["context_providers"] = SINCE["context_providers"] unless @context_providers.empty?
251
269
  found["block_renderers"] = SINCE["block_renderers"] unless @block_renderers.empty?
270
+ found["plugin.required"] = SINCE["plugin.required"] if @plugin&.dig(:required) && SINCE.key?("plugin.required")
252
271
  found["session_settings"] = SINCE["session_settings"] unless @session_settings.empty?
253
272
  found["settings"] = SINCE["settings"] unless @settings.nil?
254
273
  found["files.description"] = SINCE["files.description"] if raw["files"].is_a?(Hash) && raw["files"].values.any?(Hash)
255
274
  found["session_settings.types"] = SINCE["session_settings.types"] if raw["session_settings"].is_a?(Hash)
256
275
  found["settings.types"] = SINCE["settings.types"] if raw["settings"].is_a?(Hash)
257
- if raw["hooks"].is_a?(Hash) && raw["hooks"].values.any? { |entry| entry.is_a?(Hash) && entry.key?("events") }
258
- found["hooks.events"] = SINCE["hooks.events"]
259
- end
260
276
  found
261
277
  end
262
278
 
263
279
  # Writes a fresh manifest.yml from computed checksums at `dir`.
264
280
  # +file_descriptions+ (name → text) writes those files as mappings.
265
- def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
281
+ def self.write(dir:, name:, version:, files:, scope: nil, description: "", trust_level: nil,
266
282
  plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
267
283
  session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
268
284
  FileUtils.mkdir_p(dir)
@@ -276,11 +292,11 @@ module Samagotchi
276
292
  text = file_descriptions[key].to_s.strip
277
293
  [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
278
294
  end
279
- manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
280
295
  manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
281
296
  if plugin
282
297
  sha = plugin[:sha256].to_s
283
298
  manifest["plugin"] = { "file" => plugin[:file].to_s, "sha256" => sha.start_with?("sha256:") ? sha : "sha256:#{sha}" }
299
+ manifest["plugin"]["required"] = true if plugin[:required]
284
300
  end
285
301
  manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
286
302
  needs = parse_needs(needs)
@@ -384,7 +400,7 @@ module Samagotchi
384
400
  end
385
401
  @scripts = tolerate(problems, "scripts", {}) { parse_scripts(raw["scripts"]) }
386
402
  @plugin = tolerate(problems, "plugin", nil) { parse_plugin(raw["plugin"]) }
387
- @hooks = tolerate(problems, "hooks", {}) { parse_hooks(raw["hooks"] || {}) }
403
+ # An old kept manifest's hooks: is no problem: it isn't read (HooksRetired).
388
404
  @needs = tolerate(problems, "needs", []) { self.class.parse_needs(raw["needs"]) }
389
405
  @context_providers = tolerate(problems, "context_providers", []) { parse_context_providers(raw["context_providers"]) }
390
406
  script_names = problems.key?("scripts") ? fallback_scripts.map(&:to_s) : @scripts.keys
@@ -471,8 +487,10 @@ module Samagotchi
471
487
  end
472
488
  end
473
489
 
474
- # plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
475
- # or nil; a file that isn't a plain .rb name is a validation error.
490
+ # plugin: {file:, sha256:, required:} → {file:, sha256:} (sha "" when
491
+ # not given) with required: true when it says so, or nil; a file that
492
+ # isn't a plain .rb name, or a required: that isn't true or false, is a
493
+ # validation error.
476
494
  def parse_plugin(raw)
477
495
  return nil if raw.nil?
478
496
  raise ValidationError, "plugin: must be a mapping with file: and sha256:" unless raw.is_a?(Hash)
@@ -484,7 +502,10 @@ module Samagotchi
484
502
 
485
503
  sha = (raw["sha256"] || raw[:sha256]).to_s.strip
486
504
  sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
487
- { file: file, sha256: sha }
505
+ required = raw.key?("required") ? raw["required"] : raw[:required]
506
+ raise ValidationError, "plugin required: must be true or false, not #{required.inspect}" unless [nil, true, false].include?(required)
507
+
508
+ required ? { file: file, sha256: sha, required: true } : { file: file, sha256: sha }
488
509
  end
489
510
 
490
511
  # scripts: {file => sha256} → the same, "sha256:" prefixed; {} when absent.
@@ -536,8 +557,8 @@ module Samagotchi
536
557
 
537
558
  name
538
559
  end.uniq
539
- if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin || !@scripts.empty?)
540
- raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks, plugin or scripts)"
560
+ if !names.empty? && (!@files.empty? || @plugin || !@scripts.empty?)
561
+ raise ValidationError, "a bundle with includes: holds only its includes (no files, plugin or scripts)"
541
562
  end
542
563
 
543
564
  names
@@ -549,34 +570,6 @@ module Samagotchi
549
570
  text = raw.to_s.strip
550
571
  text.empty? ? nil : text
551
572
  end
552
-
553
- def parse_hooks(raw)
554
- return {} unless raw.is_a?(Hash)
555
-
556
- raw.each_with_object({}) do |(k, v), acc|
557
- next unless k.is_a?(String) && !k.empty?
558
- next unless v.is_a?(Hash)
559
-
560
- check_hook_events(k, v)
561
- acc[k] = BundleHook.parse(v)
562
- end
563
- end
564
-
565
- HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
566
-
567
- # A hook's events: list holds event names (one instance registered
568
- # for each); it and event: are one or the other.
569
- def check_hook_events(file, entry)
570
- return unless entry.key?("events")
571
-
572
- raw = entry["events"]
573
- raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
574
- raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
575
-
576
- raw.each do |item|
577
- raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
578
- end
579
- end
580
573
  end
581
574
  end
582
575
  end
@@ -4,6 +4,7 @@ require "digest"
4
4
  require_relative "../atomic_file"
5
5
  require_relative "file_digest"
6
6
  require_relative "installed_bundle"
7
+ require_relative "file_actions"
7
8
 
8
9
  module Samagotchi
9
10
  module MemoryBundle
@@ -13,8 +14,8 @@ module Samagotchi
13
14
 
14
15
  # 3-way merge helper for bundle upgrades.
15
16
  #
16
- # Compare base (provenance snapshot), current (on-disk), incoming (new bundle)
17
- # and classify per file. Used by Installer#run when provenance exists.
17
+ # An upgrade's merge: what FileState and FileActions' UPGRADE table say of
18
+ # a file, as an Outcome, and the text merge where both sides changed it.
18
19
  module Merger
19
20
  # What an upgrade does to one file. status names the case; base is where
20
21
  # the file's next base comes from:
@@ -26,15 +27,13 @@ module Samagotchi
26
27
  # edits, so it must never become the base (the edit is what
27
28
  # differs from the base, and the next upgrade would
28
29
  # fast-forward over it).
29
- # Installer#next_base reads it. A new outcome and its base go here; it
30
- # also needs a branch in Installer#merge_memory (which raises without
31
- # one), #next_base if its base is a new kind, and its status word in
32
- # Installer#preview_memory, #verify_memory_checksums' list and #summary.
30
+ # The same words as FileActions::Action#next_base: an Outcome is what an
31
+ # UPGRADE cell stands for to a plan, and a :decide cell's answer.
33
32
  #
34
33
  # merge is Upgrade#outcome's TextMerge::Result, so a merge runs git once
35
34
  # and its answer travels with the outcome: a :merged outcome's text, a
36
35
  # :conflict's why (nil for a real one). current_sha is the digest of the
37
- # file that merge read: write_merged checks it. .classify leaves both nil.
36
+ # file that merge read: write_merged checks it. The constants leave both nil.
38
37
  Outcome = Data.define(:status, :base, :merge, :current_sha) do
39
38
  def initialize(status:, base:, merge: nil, current_sha: nil) = super
40
39
 
@@ -65,39 +64,38 @@ module Samagotchi
65
64
  def conflicts = behind.map(&:file)
66
65
  end
67
66
 
67
+ # One file's upgrade, read once (Upgrade#decision): its FileState, the
68
+ # action the upgrade takes (a :decide cell already answered) and the
69
+ # Outcome that action stands for.
70
+ Decision = Data.define(:state, :action, :outcome)
71
+
68
72
  # One bundle's upgrade as seen before it runs: what it does to a file,
69
73
  # nothing written. The one way to ask: Installer's merge and dry run,
70
74
  # ShippedUpdate's plan and SystemBundle's dry run. Which files to ask
71
75
  # about is the caller's, and they differ (see #forecast).
72
76
  Upgrade = Data.define(:provenance, :target_dir, :source_dir) do
73
- # What the upgrade does to the file: .classify, then, where both sides
74
- # changed it, the text merge (git runs here, once per call). A mark from
75
- # an older chi fast-forwards only if no line of the file goes or its base
76
- # is proved: see .merge_outcome.
77
- # @return [Outcome]
78
- def outcome(file_key)
79
- classified = classified(file_key)
80
- return classified unless classified.conflict? || classified == FAST_FORWARD
81
-
82
- legacy = InstalledBundle.legacy_mark?(provenance.record&.files&.fetch(file_key, nil))
83
- # Asked only when the guard would otherwise fire.
84
- unproved = -> { legacy && !ShippedHistory.shipped?(provenance.name, file_key, paths(file_key)[:base]) }
85
- return Merger.merge_outcome(paths(file_key), unproved: unproved) if classified.conflict?
86
-
87
- # The guard's own case at its plainest: the file is the base, which
88
- # may be the user's text. One that only gains lines loses nothing, and
89
- # save_marked_file keeps a copy.
90
- sides = paths(file_key)
91
- return classified unless legacy && Merger.removes_lines?(File.binread(sides[:current]), File.binread(sides[:incoming]))
92
-
93
- unproved.call ? CONFLICT.with(merge: TextMerge.failed(FileStanding::LEGACY_BASE)) : classified
77
+ # What the upgrade does to the file, with what it was read from: its
78
+ # FileState, the UPGRADE action (a :decide cell answered by #outcome's
79
+ # merge or legacy guard, through FileActions.decided) and the Outcome.
80
+ # @param state [FileState] the file's, when the caller has read it
81
+ # @return [Decision]
82
+ def decision(file_key, state = state(file_key))
83
+ action = FileActions.for(:upgrade, state)
84
+ outcome = outcome_of(file_key, state, action)
85
+ Decision.new(state: state, action: action.do == :decide ? FileActions.decided(outcome) : action, outcome: outcome)
94
86
  end
95
87
 
96
- # .classify alone: no merge is tried.
88
+ # What the upgrade does to the file: its FileState's UPGRADE cell, and
89
+ # where both sides changed it, the text merge (git runs here, once per
90
+ # call). A mark from an older chi fast-forwards only if no line of the
91
+ # file goes or its base is proved: see .merge_outcome.
97
92
  # @return [Outcome]
98
- def classified(file_key)
99
- sides = paths(file_key)
100
- Merger.classify(base_path: sides[:base], current_path: sides[:current], incoming_path: sides[:incoming])
93
+ def outcome(file_key) = decision(file_key).outcome
94
+
95
+ # The file as the upgrade meets it (FileState, with the source).
96
+ # @return [FileState]
97
+ def state(file_key)
98
+ FileState.of(file_key, record: provenance.record, provenance: provenance, target_dir: target_dir, source_dir: source_dir)
101
99
  end
102
100
 
103
101
  # The ones among +file_keys+ that would merge or end in a conflict. The
@@ -138,38 +136,50 @@ module Samagotchi
138
136
  { base: provenance.base_path(file_key), current: File.join(target_dir, file_key),
139
137
  incoming: File.join(source_dir, file_key) }
140
138
  end
141
- end
142
139
 
143
- # Classify a single file for upgrade.
144
- # @return [Outcome]
145
- def self.classify(base_path:, current_path:, incoming_path:)
146
- base_exists = base_path && File.exist?(base_path)
147
- current_exists = File.exist?(current_path)
148
- incoming_exists = File.exist?(incoming_path)
149
-
150
- return NO_INCOMING unless incoming_exists
151
-
152
- unless current_exists
153
- return INSTALL
140
+ private
141
+
142
+ # The Outcome a cell stands for; a :decide cell's is the text merge
143
+ # (:diverged) or the legacy guard ([:pristine, :legacy]).
144
+ def outcome_of(file_key, state, action)
145
+ return NO_INCOMING if state.digests.incoming.nil?
146
+
147
+ case action.do
148
+ when :decide then decide(file_key, state)
149
+ when :write_incoming then action.word == :installed ? INSTALL : FAST_FORWARD
150
+ when :keep
151
+ return no_base(state) if action.behind?
152
+
153
+ action.word == :kept ? KEEP : NOOP
154
+ # A file the bundle doesn't own, the user's: there is no base to merge from.
155
+ when :skip then no_base(state)
156
+ # It can't be read: the read raises, as the merge would.
157
+ when :refuse then FileDigest.hex(paths(file_key)[:current])
158
+ else raise ArgumentError, "no outcome for #{action.do.inspect} (#{file_key})"
159
+ end
154
160
  end
155
161
 
156
- # No base (legacy bundle without provenance or first file) -> treat as modified
157
- unless base_exists
158
- return FileDigest.hex(current_path) == FileDigest.hex(incoming_path) ? NOOP : CONFLICT
162
+ # No base to merge from: the file stays behind. With none on disk
163
+ # TextMerge::NO_BASE says why; one the checksum contradicts (the file
164
+ # equals it, yet is edited) may be the user's own text, as an older
165
+ # chi's can (FileStanding::LEGACY_BASE).
166
+ def no_base(state)
167
+ why = state.digests.base.nil? ? TextMerge::NO_BASE : FileStanding::LEGACY_BASE
168
+ CONFLICT.with(merge: TextMerge.failed(why))
159
169
  end
160
170
 
161
- base_digest = FileDigest.hex(base_path)
162
- current_digest = FileDigest.hex(current_path)
163
- incoming_digest = FileDigest.hex(incoming_path)
164
-
165
- if current_digest == incoming_digest
166
- NOOP
167
- elsif current_digest == base_digest
168
- FAST_FORWARD
169
- elsif incoming_digest == base_digest
170
- KEEP
171
- else
172
- CONFLICT
171
+ def decide(file_key, state)
172
+ # Asked only when the guard would otherwise fire.
173
+ unproved = -> { state.mark == :legacy && !ShippedHistory.shipped?(provenance.name, file_key, paths(file_key)[:base]) }
174
+ return Merger.merge_outcome(paths(file_key), unproved: unproved) unless state.kind == :pristine
175
+
176
+ # The guard's own case at its plainest: the file is the base, which
177
+ # may be the user's text. One that only gains lines loses nothing, and
178
+ # its save_first keeps a copy (Installer#save_first).
179
+ sides = paths(file_key)
180
+ return FAST_FORWARD unless Merger.removes_lines?(File.binread(sides[:current]), File.binread(sides[:incoming]))
181
+
182
+ unproved.call ? CONFLICT.with(merge: TextMerge.failed(FileStanding::LEGACY_BASE)) : FAST_FORWARD
173
183
  end
174
184
  end
175
185
 
@@ -243,12 +253,6 @@ module Samagotchi
243
253
  def self.digest(path)
244
254
  FileDigest.hex(path)
245
255
  end
246
-
247
- def self.current_modified?(base_path, current_path)
248
- return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
249
-
250
- FileDigest.hex(base_path) != FileDigest.hex(current_path)
251
- end
252
256
  end
253
257
  end
254
258
  end
@@ -3,11 +3,12 @@
3
3
  module Samagotchi
4
4
  module MemoryBundle
5
5
  # The plugin an installed bundle's record (manifest.json) names: its
6
- # file under plugin/ and the sha256 recorded at install ("" when none
6
+ # file under plugin/, the sha256 recorded at install ("" when none
7
7
  # is; either "sha256:<hex>" or a bare hex, Provenance.recorded_sha
8
- # reads both).
9
- PluginRef = Data.define(:file, :sha256) do
10
- def initialize(file:, sha256: "")
8
+ # reads both), and whether its manifest called it required (a guardrail
9
+ # plugin: plugin: required: true; an older chi ignores the key).
10
+ PluginRef = Data.define(:file, :sha256, :required) do
11
+ def initialize(file:, sha256: "", required: false)
11
12
  super
12
13
  end
13
14
 
@@ -20,7 +21,7 @@ module Samagotchi
20
21
 
21
22
  h = raw.transform_keys(&:to_s)
22
23
  file = h["file"].to_s
23
- file.empty? ? nil : new(file: file, sha256: h["sha256"].to_s)
24
+ file.empty? ? nil : new(file: file, sha256: h["sha256"].to_s, required: h["required"] == true)
24
25
  end
25
26
  end
26
27
  end
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  # installed: members installed now; already: members found installed
28
28
  # (recorded, not reinstalled); skipped and failed: name => why;
29
29
  # kept_own: member installed now => its memory files the user already
30
- # had, left as they are and not owned (Installer#skip_existing);
30
+ # had, left as they are and not owned (the INSTALL rows of a file not owned);
31
31
  # trash_copies: "member: line" for what --force copied to the trash
32
32
  # (Installer#trash_copy_line).
33
33
  InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, :kept_own, :trash_copies, keyword_init: true) do
@@ -140,11 +140,11 @@ module Samagotchi
140
140
  private_class_method :install_member
141
141
 
142
142
  # An Installer result for a file the user already had, with other
143
- # bytes: skipped ("already exists"), or a dry run's would_skip. One
144
- # with the bundle's bytes ("already up to date") isn't named: nothing
145
- # of the bundle's is missing (it stays the user's all the same).
143
+ # bytes: kept as the user's own (:kept_own), or a dry run's
144
+ # :would_skip. One with the bundle's bytes (:up_to_date) isn't named:
145
+ # nothing of the bundle's is missing (it stays the user's all the same).
146
146
  def kept_own?(entry)
147
- entry[:status] == "would_skip" || (entry[:status] == "skipped" && entry[:reason] == "already exists")
147
+ %i[would_skip kept_own].include?(entry.word)
148
148
  end
149
149
  private_class_method :kept_own?
150
150