samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -43,14 +43,14 @@ module Samagotchi
43
43
  # @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
44
44
  def installed(shipped: self.shipped)
45
45
  by_name = shipped.to_h { |s| [s.name, s] }
46
- Provenance.each_installed.map do |name, data|
47
- next Installed.new(name: name, error: "manifest.json unreadable") if data[:error]
46
+ Provenance.each_installed.map do |name, bundle|
47
+ next Installed.new(name: name, error: "manifest.json unreadable") if bundle.error?
48
48
 
49
49
  ship = by_name[name]
50
- Installed.new(name: name, version: data[:version], scope: data[:scope],
51
- files: (data[:files] || {}).size, installed_at: data[:installed_at],
52
- upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil,
53
- includes: data[:includes].is_a?(Array) ? data[:includes].map(&:to_s) : nil)
50
+ Installed.new(name: name, version: bundle.version, scope: bundle.scope,
51
+ files: bundle.files.size, installed_at: bundle.installed_at,
52
+ upgrade: ship && newer?(ship.version, bundle.version) ? ship : nil,
53
+ includes: bundle.includes)
54
54
  end
55
55
  end
56
56
 
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
+ require_relative "bundle_hook"
7
8
 
8
9
  module Samagotchi
9
10
  module MemoryBundle
@@ -53,7 +54,8 @@ module Samagotchi
53
54
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
54
55
 
55
56
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
56
- # the description a mapping gave, for the files that have one.
57
+ # the description a mapping gave, for the files that have one; hooks:
58
+ # name → BundleHook.
57
59
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
58
60
  :scripts, :context_providers
59
61
 
@@ -97,15 +99,11 @@ module Samagotchi
97
99
  raw.start_with?("sha256:") ? raw[7..] : raw
98
100
  end
99
101
 
100
- # Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
102
+ # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
103
+ # when it declares none (nothing to verify).
101
104
  def checksum_for_hook(basename)
102
- raw = @hooks[basename]
103
- return nil unless raw
104
-
105
- sha = raw[:sha256] || raw["sha256"]
106
- return nil unless sha
107
-
108
- sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
105
+ hex = @hooks[basename]&.hex
106
+ hex unless hex.nil? || hex.empty?
109
107
  end
110
108
 
111
109
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
@@ -142,14 +140,7 @@ module Samagotchi
142
140
  text = file_descriptions[key].to_s.strip
143
141
  [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
144
142
  end
145
- if hooks && !hooks.empty?
146
- # Normalize hooks to string-keyed with sha256 prefix preserved
147
- manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
148
- h = v.transform_keys(&:to_s)
149
- h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
150
- h
151
- end
152
- end
143
+ manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
153
144
  manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
154
145
  if plugin
155
146
  sha = plugin[:sha256].to_s
@@ -306,18 +297,7 @@ module Samagotchi
306
297
  next unless k.is_a?(String) && !k.empty?
307
298
  next unless v.is_a?(Hash)
308
299
 
309
- sha = (v["sha256"] || v[:sha256] || "").to_s
310
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
311
- event = (v["event"] || v[:event] || "").to_s.strip
312
- on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
313
- on_error = "skip" if on_error.empty?
314
- priority = (v["priority"] || v[:priority] || 100).to_i
315
- acc[k] = {
316
- sha256: sha,
317
- event: event,
318
- on_error: on_error,
319
- priority: priority
320
- }
300
+ acc[k] = BundleHook.parse(v)
321
301
  end
322
302
  end
323
303
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module MemoryBundle
5
+ # The plugin an installed bundle's record (manifest.json) names: its
6
+ # file under plugin/ and the sha256 recorded at install ("" when none
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: "")
11
+ super
12
+ end
13
+
14
+ # @param raw [Hash, PluginRef, nil] the record's plugin: mapping
15
+ # (symbol or string keys)
16
+ # @return [PluginRef, nil] nil when there is none or it names no file
17
+ def self.parse(raw)
18
+ return raw if raw.is_a?(PluginRef)
19
+ return nil unless raw.is_a?(Hash)
20
+
21
+ h = raw.transform_keys(&:to_s)
22
+ file = h["file"].to_s
23
+ file.empty? ? nil : new(file: file, sha256: h["sha256"].to_s)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -46,32 +46,32 @@ module Samagotchi
46
46
  false
47
47
  end
48
48
 
49
- # Whether the installed bundle +name+ (its provenance +data+) is a
50
- # meta: it records includes, or it came from chi and chi ships it as
51
- # a meta.
52
- def installed_meta?(name, data, shipped_dir: SourceNormalizer::SHIPPED_DIR)
53
- return false unless data.is_a?(Hash)
54
- return true if data[:includes].is_a?(Array)
55
-
56
- data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
49
+ # Whether the installed bundle +name+ (its record +bundle+, an
50
+ # InstalledBundle or nil) is a meta: it records includes, or it came
51
+ # from chi and chi ships it as a meta.
52
+ def installed_meta?(name, bundle, shipped_dir: SourceNormalizer::SHIPPED_DIR)
53
+ return false unless bundle
54
+ return true if bundle.profile?
55
+
56
+ bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
57
57
  end
58
58
 
59
59
  # The members the meta has recorded: none when it isn't installed,
60
60
  # all of +manifest+'s (the shipped meta, nil when chi ships none) when
61
61
  # its provenance has no includes: an unknown record is taken as
62
62
  # everything.
63
- def recorded(data, manifest)
64
- return [] unless data
65
- return manifest&.includes || [] unless data[:includes].is_a?(Array)
63
+ def recorded(bundle, manifest)
64
+ return [] unless bundle
65
+ return manifest&.includes || [] unless bundle.profile?
66
66
 
67
- data[:includes].map(&:to_s)
67
+ bundle.includes
68
68
  end
69
69
 
70
70
  def installed_names = Provenance.each_installed.map { |name, _| name }
71
71
 
72
72
  # The one rule.
73
- def to_install(manifest, data = Provenance.new(name: manifest.name).read, installed: installed_names)
74
- manifest.includes - recorded(data, manifest) - installed
73
+ def to_install(manifest, bundle = Provenance.new(name: manifest.name).record, installed: installed_names)
74
+ manifest.includes - recorded(bundle, manifest) - installed
75
75
  end
76
76
 
77
77
  # Installs the shipped meta at +dir+: its to_install members from the
@@ -80,13 +80,13 @@ module Samagotchi
80
80
  # @return [InstallResult]
81
81
  def install(dir, shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION, dry_run: false, force: false)
82
82
  manifest = Manifest.read(dir: dir)
83
- data = Provenance.new(name: manifest.name).read
84
- was = recorded(data, manifest)
83
+ bundle = Provenance.new(name: manifest.name).record
84
+ was = recorded(bundle, manifest)
85
85
  installed = installed_names
86
86
  result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
87
87
  already: (manifest.includes - was) & installed, skipped: {}, failed: {})
88
88
 
89
- to_install(manifest, data, installed: installed).each do |member|
89
+ to_install(manifest, bundle, installed: installed).each do |member|
90
90
  install_member(member, shipped_dir, chi_version, dry_run, force, result)
91
91
  end
92
92
  return result if dry_run
@@ -94,7 +94,7 @@ module Samagotchi
94
94
  now = was | result.installed | result.already
95
95
  includes = (manifest.includes & now) + (now - manifest.includes)
96
96
  # Nothing new: the record stays as it is (installed_at too).
97
- return result if data && data[:version] == manifest.version && data[:source] == dir && data[:includes] == includes
97
+ return result if bundle && bundle.version == manifest.version && bundle.source == dir && bundle.includes == includes
98
98
 
99
99
  Provenance.new(name: manifest.name).write(
100
100
  files: {}, scope: "system", version: manifest.version, source_path: dir,
@@ -123,12 +123,12 @@ module Samagotchi
123
123
  # @return [UninstallResult]
124
124
  # @raise [Uninstaller::UninstallError] when +name+ isn't installed
125
125
  def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
126
- data = Provenance.new(name: name).read
127
- raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless data
126
+ bundle = Provenance.new(name: name).record
127
+ raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
128
128
 
129
129
  shipped = File.join(shipped_dir, name)
130
130
  manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
131
- members = recorded(data, manifest)
131
+ members = recorded(bundle, manifest)
132
132
  result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false, trash: {}, warnings: [])
133
133
  (members & installed_names).each do |member|
134
134
  uninstaller = Uninstaller.new(name: member, force: force)
@@ -6,6 +6,8 @@ require "date"
6
6
  require "fileutils"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../memory_paths"
9
+ require_relative "bundle_hook"
10
+ require_relative "installed_bundle"
9
11
 
10
12
  module Samagotchi
11
13
  module MemoryBundle
@@ -46,9 +48,10 @@ module Samagotchi
46
48
  end
47
49
 
48
50
  # The plugin file as installed, or nil when the bundle has none.
49
- def plugin_path(data = read)
50
- file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
51
- file.empty? ? nil : File.join(plugin_dir, file)
51
+ # @param bundle [InstalledBundle, nil] its record (read when not given)
52
+ def plugin_path(bundle = record)
53
+ file = bundle&.plugin&.file
54
+ file ? File.join(plugin_dir, file) : nil
52
55
  end
53
56
 
54
57
  # The plugin's base snapshot (bases/plugin/<file>), for bundle diff.
@@ -56,14 +59,14 @@ module Samagotchi
56
59
  File.join(@bundle_dir, "bases", "plugin", file.to_s)
57
60
  end
58
61
 
59
- # Yields [name, data] for each installed bundle (a dir under
60
- # bundles_dir holding a manifest.json, dot dirs aside), by name. data
61
- # is the manifest with symbol keys, or {error:} when it doesn't parse
62
- # or isn't an object; the rest still come.
62
+ # Yields [name, bundle] for each installed bundle (a dir under
63
+ # bundles_dir holding a manifest.json, dot dirs aside), by name. bundle
64
+ # is its InstalledBundle, one with error set when manifest.json doesn't
65
+ # parse or isn't an object; the rest still come.
63
66
  #
64
67
  # With holding: (:hooks, :guardrails or :plugin) only the bundles
65
68
  # whose manifest has a non-empty mapping under that key come, and a
66
- # manifest that doesn't parse comes as {error:} only when its bundle
69
+ # manifest that doesn't parse comes (with error) only when its bundle
67
70
  # has that dir (hooks/, guardrails/, plugin/): what it would load
68
71
  # can't be checked. One that isn't an object is skipped there.
69
72
  # @param dir [String] the bundles dir (bundles_dir by default)
@@ -78,15 +81,14 @@ module Samagotchi
78
81
  begin
79
82
  data = JSON.parse(File.read(mjson), symbolize_names: true)
80
83
  rescue JSON::ParserError, SystemCallError => e
81
- yield name, { error: "manifest.json is unreadable: #{e.message}" } if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
84
+ if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
85
+ yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
86
+ end
82
87
  next
83
88
  end
84
- if holding
85
- next unless data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?
86
- elsif !data.is_a?(Hash)
87
- data = { error: "manifest.json is not an object" }
88
- end
89
- yield name, data
89
+ next if holding && !(data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?)
90
+
91
+ yield name, InstalledBundle.parse(name, data)
90
92
  end
91
93
  end
92
94
 
@@ -95,10 +97,9 @@ module Samagotchi
95
97
  # A project-scoped record doesn't say which project: one from another
96
98
  # repo counts too, which errs on keeping the file.
97
99
  def self.claimants(file_key, scope:, except: nil, dir: bundles_dir)
98
- each_installed(dir: dir).filter_map do |name, data|
99
- next if name == except || data[:error]
100
- next unless (data[:scope].to_s.empty? ? "system" : data[:scope].to_s) == scope.to_s
101
- next unless data[:files].is_a?(Hash) && data[:files].keys.map(&:to_s).include?(file_key.to_s)
100
+ each_installed(dir: dir).filter_map do |name, bundle|
101
+ next if name == except || bundle.error?
102
+ next unless bundle.effective_scope == scope.to_s && bundle.owns?(file_key)
102
103
 
103
104
  name
104
105
  end
@@ -145,11 +146,10 @@ module Samagotchi
145
146
  bases_dir = File.join(@bundle_dir, "bases")
146
147
  FileUtils.mkdir_p(bases_dir)
147
148
 
148
- # Read existing manifest to merge file entries and find stale files.
149
- # read symbolizes keys; stringify them so incoming string keys replace
150
- # them instead of sitting next to them as duplicates.
151
- existing = read
152
- merged_entries = existing && existing[:files] ? existing[:files].transform_keys(&:to_s) : {}
149
+ # The existing record: its file entries merge with these, and its
150
+ # files and hooks not in this install are stale.
151
+ existing = record
152
+ merged_entries = existing ? existing.files.dup : {}
153
153
 
154
154
  files.each do |file_key, file_path|
155
155
  content = File.read(file_path)
@@ -162,10 +162,9 @@ module Samagotchi
162
162
  end
163
163
 
164
164
  # Prune stale base snapshots for files no longer in the bundle.
165
- if existing && existing[:files]
166
- existing[:files].keys.each do |old_key|
167
- old_key_str = old_key.to_s
168
- next if files.key?(old_key_str) || files.key?(old_key)
165
+ if existing
166
+ existing.files.each_key do |old_key_str|
167
+ next if files.key?(old_key_str) || files.key?(old_key_str.to_sym)
169
168
 
170
169
  old_base = File.join(bases_dir, old_key_str)
171
170
  FileUtils.rm_f(old_base)
@@ -175,47 +174,29 @@ module Samagotchi
175
174
 
176
175
  # Hooks provenance: persist metadata and base snapshots for hooks
177
176
  hooks_map = {}
178
- # existing hooks for pruning
179
- existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
180
- # Normalize incoming hooks (basename => metadata hash)
177
+ # Incoming hooks: basename => BundleHook (or its metadata hash)
181
178
  normalized_hooks = {}
182
179
  if hooks.is_a?(Hash)
183
180
  hooks.each do |k, v|
184
181
  next unless k.is_a?(String) && !k.empty?
185
- next unless v.is_a?(Hash)
186
-
187
- hv = v.transform_keys(&:to_sym)
188
- sha = (hv[:sha256] || "").to_s
189
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
190
- normalized_hooks[k] = {
191
- sha256: sha,
192
- event: (hv[:event] || "").to_s,
193
- on_error: (hv[:on_error] || "skip").to_s,
194
- priority: (hv[:priority] || 100).to_i
195
- }
182
+ next unless v.is_a?(Hash) || v.is_a?(BundleHook)
183
+
184
+ normalized_hooks[k] = BundleHook.parse(v)
196
185
  end
197
186
  end
198
187
  # Prune stale hook bases
199
- if existing_hooks.is_a?(Hash)
200
- existing_hooks.keys.each do |old_key|
201
- old_key_str = old_key.to_s
202
- unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
203
- old_base = File.join(bases_dir, old_key_str)
204
- FileUtils.rm_f(old_base)
205
- end
206
- end
188
+ existing&.hooks&.each_key do |old_key_str|
189
+ FileUtils.rm_f(File.join(bases_dir, old_key_str)) unless normalized_hooks.key?(old_key_str)
207
190
  end
208
191
  # Save base snapshots for hooks where a file path was provided, and
209
192
  # record the sha256 of the installed file itself: the load-time check
210
193
  # compares against it, and a declared sha can be wrong (installing
211
194
  # only warns about that).
212
195
  hooks_files = {} unless hooks_files.is_a?(Hash)
213
- normalized_hooks.each do |k, meta|
196
+ normalized_hooks.each do |k, hook|
214
197
  src = hooks_files[k] || hooks_files[k.to_sym]
215
- if src && File.exist?(src.to_s)
216
- meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
217
- end
218
- hooks_map[k] = meta.transform_keys(&:to_s)
198
+ hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
199
+ hooks_map[k] = hook.to_record
219
200
  next unless src && File.exist?(src.to_s)
220
201
 
221
202
  begin
@@ -285,7 +266,15 @@ module Samagotchi
285
266
  write_manifest(raw)
286
267
  end
287
268
 
288
- # Reads provenance data (returns nil if not installed).
269
+ # The installed bundle's record, nil when it isn't installed.
270
+ # @return [InstalledBundle, nil]
271
+ # @raise [JSON::ParserError] manifest.json doesn't parse
272
+ def record
273
+ InstalledBundle.read(@name, File.join(@bundle_dir, "manifest.json"))
274
+ end
275
+
276
+ # manifest.json as it is on disk, symbol keys (nil if not installed).
277
+ # Readers take #record.
289
278
  def read
290
279
  manifest_path = File.join(@bundle_dir, "manifest.json")
291
280
  return nil unless File.exist?(manifest_path)
@@ -308,7 +297,7 @@ module Samagotchi
308
297
 
309
298
  # Checks whether the bundle is installed.
310
299
  def installed?
311
- read.nil? ? false : true
300
+ !record.nil?
312
301
  end
313
302
  end
314
303
  end
@@ -45,8 +45,8 @@ module Samagotchi
45
45
  # @return [Array<Row>] by name
46
46
  def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
47
47
  shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
48
- installed.map do |name, data|
49
- plan_row(name, data, shipped[name], shipped_dir, chi_version)
48
+ installed.map do |name, bundle|
49
+ plan_row(name, bundle, shipped[name], shipped_dir, chi_version)
50
50
  end
51
51
  end
52
52
 
@@ -88,24 +88,24 @@ module Samagotchi
88
88
  end
89
89
  end
90
90
 
91
- # [name, data] for each installed bundle but the system one, by name.
91
+ # [name, InstalledBundle] for each installed bundle but the system one, by name.
92
92
  def installed
93
93
  Provenance.each_installed.reject { |name, _| name == SystemBundle::BUNDLE_NAME }
94
94
  end
95
95
 
96
- def plan_row(name, data, ship, shipped_dir, chi_version)
96
+ def plan_row(name, bundle, ship, shipped_dir, chi_version)
97
97
  row = Row.new(name: name, kept: [], replaced: [])
98
- return skip(row, "manifest.json unreadable") if data[:error]
98
+ return skip(row, "manifest.json unreadable") if bundle.error?
99
99
 
100
- row.from = data[:version].to_s
101
- row.scope = data[:scope].to_s.empty? ? "system" : data[:scope].to_s
102
- return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
100
+ row.from = bundle.version.to_s
101
+ row.scope = bundle.effective_scope
102
+ return skip(row, "not from chi") unless ship && bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
103
103
 
104
104
  row.to = ship.version.to_s
105
105
  return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
106
106
 
107
107
  row.source_dir = File.join(shipped_dir, ship.source)
108
- return profile_row(row, data, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
108
+ return profile_row(row, bundle, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
109
109
  return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
110
110
 
111
111
  manifest = Manifest.read(dir: row.source_dir)
@@ -115,20 +115,20 @@ module Samagotchi
115
115
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
116
116
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
117
117
 
118
- row.kept = conflicts(row, data)
119
- row.replaced = edited_executables(name, data)
118
+ row.kept = conflicts(row, bundle)
119
+ row.replaced = edited_executables(name, bundle)
120
120
  row.status = :would_update
121
121
  row
122
122
  end
123
123
 
124
- def profile_row(row, data, shipped_dir, chi_version)
124
+ def profile_row(row, bundle, shipped_dir, chi_version)
125
125
  manifest = Manifest.read(dir: row.source_dir)
126
126
  newer = Listing.newer?(row.to, row.from)
127
127
  if newer && Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
128
128
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
129
129
  end
130
130
 
131
- row.adds = Profile.to_install(manifest, data).reject do |member|
131
+ row.adds = Profile.to_install(manifest, bundle).reject do |member|
132
132
  Manifest.requires_chi_failure(Manifest.read(dir: File.join(shipped_dir, member)).requires_chi, chi_version)
133
133
  rescue Manifest::ValidationError, Psych::Exception
134
134
  false # apply reports it failing
@@ -147,12 +147,10 @@ module Samagotchi
147
147
 
148
148
  # The bundle's .md files whose local edits conflict with the shipped
149
149
  # version (a same-name file it didn't install is skipped, not merged).
150
- def conflicts(row, data)
150
+ def conflicts(row, bundle)
151
151
  provenance = Provenance.new(name: row.name)
152
152
  target_dir = MemoryPaths.scope_dir!(row.scope)
153
- return [] unless data[:files].is_a?(Hash)
154
-
155
- owned = data[:files].keys.map(&:to_s)
153
+ owned = bundle.files.keys
156
154
  (Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
157
155
  Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
158
156
  incoming_path: File.join(row.source_dir, key)) == :conflict
@@ -161,22 +159,22 @@ module Samagotchi
161
159
 
162
160
  # Installed hooks, rules and the plugin whose sha differs from the
163
161
  # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
164
- def edited_executables(name, data)
162
+ def edited_executables(name, bundle)
165
163
  provenance = Provenance.new(name: name)
166
164
  edited = []
167
- (data[:hooks] || {}).each do |file, meta|
168
- edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file.to_s), meta)
165
+ bundle.hooks.each do |file, hook|
166
+ edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file), hook.sha256)
169
167
  end
170
- (data[:guardrails] || {}).each do |file, meta|
171
- edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file.to_s), meta)
168
+ bundle.guardrails.each do |file, sha|
169
+ edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
172
170
  end
173
- plugin = provenance.plugin_path(data)
174
- edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, data[:plugin])
171
+ plugin = provenance.plugin_path(bundle)
172
+ edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, bundle.plugin.sha256)
175
173
  edited
176
174
  end
177
175
 
178
- def edited?(path, meta)
179
- recorded = meta.is_a?(Hash) ? meta[:sha256] : nil
176
+ # Whether the file differs from its recorded sha (none recorded: no).
177
+ def edited?(path, recorded)
180
178
  return false if Provenance.recorded_sha(recorded).empty? || !File.file?(path)
181
179
 
182
180
  !Provenance.sha_matches?(path, recorded)
@@ -17,30 +17,28 @@ module Samagotchi
17
17
  # unchecked (no disk reads, nothing counted as missing).
18
18
  def self.bundle_status(name, scope_override: nil)
19
19
  provenance = Provenance.new(name: name)
20
- data = provenance.read
21
- return nil unless data
20
+ bundle = provenance.record
21
+ return nil unless bundle
22
22
 
23
- raw_scope = scope_override || data[:scope]&.to_s
24
- scope = raw_scope.nil? || raw_scope.strip.empty? ? "system" : raw_scope
23
+ scope = (scope_override ? bundle.with(scope: scope_override) : bundle).effective_scope
25
24
  target_dir = MemoryPaths.scope_dir(scope)
26
- files = data[:files] || {}
25
+ files = bundle.files
27
26
  details = {}
28
- files.each do |file_key, meta|
29
- file_key_str = file_key.to_s
27
+ files.each do |file_key_str, meta|
30
28
  next details[file_key_str] = unchecked_file(meta) unless target_dir
31
29
 
32
30
  target_path = File.join(target_dir, file_key_str)
33
31
  base_path = provenance.base_path(file_key_str)
34
- stored_checksum = meta[:checksum] || meta["checksum"]
32
+ stored_checksum = meta[:checksum]
35
33
  current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
36
34
  base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
37
35
  modified = base_checksum && current_checksum && base_checksum != current_checksum
38
36
  missing = !File.exist?(target_path)
39
37
  # A model overlay has no index line (Installer#note_overlay).
40
- overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
38
+ overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys, target_dir: target_dir)
41
39
  index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
42
40
  details[file_key_str] = {
43
- conflict: meta.is_a?(Hash) && meta[:conflict] == true,
41
+ conflict: meta[:conflict] == true,
44
42
  stored_checksum: stored_checksum,
45
43
  current_checksum: current_checksum,
46
44
  base_checksum: base_checksum,
@@ -52,23 +50,23 @@ module Samagotchi
52
50
  base_path: base_path
53
51
  }
54
52
  end
55
- { provenance: data, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
56
- target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
57
- hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
53
+ { provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
54
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
55
+ hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle) }
58
56
  end
59
57
 
60
58
  # Why none of the bundle's hooks load (this chi doesn't meet its
61
59
  # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
62
- def self.hooks_requires_failure(data)
63
- return nil if (data[:hooks] || {}).empty?
60
+ def self.hooks_requires_failure(bundle)
61
+ return nil if bundle.hooks.empty?
64
62
 
65
- Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
63
+ Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
66
64
  end
67
65
 
68
66
  # The stored needs, each with found: from this process's PATH (the
69
67
  # shell's; a worker's PATH can differ, see docs/memory.md).
70
- def self.needs_status(data, path: ENV["PATH"])
71
- Manifest.parse_needs(data[:needs]).map do |need|
68
+ def self.needs_status(bundle, path: ENV["PATH"])
69
+ Manifest.parse_needs(bundle.needs).map do |need|
72
70
  need.merge(found: BundleNeeds.found?(need[:command], path: path))
73
71
  end
74
72
  rescue Manifest::ValidationError
@@ -86,20 +84,20 @@ module Samagotchi
86
84
  # The installed plugin: {file:, path:, state:, requires_chi:,
87
85
  # requires_failure:}; state is "ok", "modified" (its sha256 differs
88
86
  # from the installed one: it won't load) or "missing". nil without one.
89
- def self.plugin_status(provenance, data)
90
- path = provenance.plugin_path(data)
87
+ def self.plugin_status(provenance, bundle)
88
+ path = provenance.plugin_path(bundle)
91
89
  return nil unless path
92
90
 
93
91
  state = if !File.file?(path) then "missing"
94
- elsif Provenance.sha_matches?(path, data[:plugin][:sha256]) then "ok"
92
+ elsif Provenance.sha_matches?(path, bundle.plugin.sha256) then "ok"
95
93
  else "modified"
96
94
  end
97
- { file: File.basename(path), path: path, state: state, requires_chi: data[:requires_chi],
98
- requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
95
+ { file: File.basename(path), path: path, state: state, requires_chi: bundle.requires_chi,
96
+ requires_failure: Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION) }
99
97
  end
100
98
 
101
99
  def self.unchecked_file(meta)
102
- { conflict: meta.is_a?(Hash) && meta[:conflict] == true, stored_checksum: meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil,
100
+ { conflict: meta[:conflict] == true, stored_checksum: meta[:checksum],
103
101
  unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
104
102
  end
105
103
  private_class_method :unchecked_file
@@ -72,10 +72,10 @@ module Samagotchi
72
72
  to = gem_manifest.version.to_s
73
73
 
74
74
  with_lock do
75
- data = Provenance.new(name: BUNDLE_NAME).read
76
- from = data && data[:version].to_s
75
+ bundle = Provenance.new(name: BUNDLE_NAME).record
76
+ from = bundle&.version.to_s
77
77
 
78
- if data.nil?
78
+ if bundle.nil?
79
79
  dry_run ? result(:installed, to: to) : install_fresh(to)
80
80
  elsif installed_newer?(from, to)
81
81
  # Running an older checkout/gem: never downgrade the user's system memories
@@ -174,8 +174,7 @@ module Samagotchi
174
174
  # here and changed in the shipped bundle (a dry run's kept).
175
175
  def conflicting(gem_manifest)
176
176
  provenance = Provenance.new(name: BUNDLE_NAME)
177
- owned = (provenance.read || {})[:files]
178
- owned = owned.is_a?(Hash) ? owned.keys.map(&:to_s) : []
177
+ owned = provenance.record&.files&.keys || []
179
178
  (gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
180
179
  Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
181
180
  incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict