samagotchi 0.151.0 → 0.152.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/bundle-authoring.md +4 -4
  4. data/docs/cli.md +32 -7
  5. data/docs/guardrails.md +4 -0
  6. data/docs/internals/block-renderers.md +3 -1
  7. data/docs/internals/bundles.md +48 -14
  8. data/docs/internals/objectives.md +6 -4
  9. data/docs/memory.md +6 -1
  10. data/docs/plugins.md +51 -14
  11. data/docs/releasing.md +7 -2
  12. data/docs/sessions.md +1 -1
  13. data/lib/samagotchi/block_renderers.rb +9 -8
  14. data/lib/samagotchi/bundle_command/check.rb +43 -3
  15. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  16. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  17. data/lib/samagotchi/bundle_command.rb +88 -10
  18. data/lib/samagotchi/bundle_needs.rb +4 -6
  19. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  20. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  28. data/lib/samagotchi/child_env.rb +15 -0
  29. data/lib/samagotchi/context_providers.rb +42 -10
  30. data/lib/samagotchi/engine.rb +27 -4
  31. data/lib/samagotchi/extension_load.rb +4 -3
  32. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  33. data/lib/samagotchi/guardrails/approval.rb +2 -2
  34. data/lib/samagotchi/guardrails/rules.rb +1 -0
  35. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  37. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  38. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  39. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  40. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  41. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  42. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  43. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  44. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  45. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  46. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  47. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  48. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  49. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  50. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  51. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  53. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  55. data/lib/samagotchi/plugin/loader.rb +1 -1
  56. data/lib/samagotchi/session_config.rb +5 -4
  57. data/lib/samagotchi/system_one/record.rb +10 -2
  58. data/lib/samagotchi/tools/delegate.rb +20 -2
  59. data/lib/samagotchi/update_command.rb +13 -2
  60. data/lib/samagotchi/version.rb +1 -1
  61. data/lib/samagotchi/web/app.rb +2 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  63. data/lib/samagotchi/web/session_hub.rb +6 -0
  64. metadata +4 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "time"
4
+ require_relative "file_digest"
5
5
 
6
6
  module Samagotchi
7
7
  module MemoryBundle
@@ -156,7 +156,7 @@ module Samagotchi
156
156
  # read isn't known to be level. No digest (an older mark): it isn't known
157
157
  # either, and a retry would have settled it.
158
158
  def differs?(path, incoming_sha)
159
- incoming_sha.nil? || Digest::SHA256.file(path).hexdigest != incoming_sha
159
+ incoming_sha.nil? || FileDigest.hex(path) != incoming_sha
160
160
  rescue SystemCallError
161
161
  true
162
162
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "json"
4
+ require_relative "file_digest"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "bundle_hook"
7
7
  require_relative "plugin_ref"
@@ -10,11 +10,15 @@ module Samagotchi
10
10
  module MemoryBundle
11
11
  # Only what reports on an edited file needs it.
12
12
  autoload :FileStanding, File.expand_path("file_standing", __dir__)
13
+ autoload :InstalledManifest, File.expand_path("installed_manifest", __dir__)
13
14
 
14
15
  # An installed bundle's record, as Provenance#write keeps it in
15
- # <bundles>/<name>/manifest.json. Reading is tolerant: an older record
16
- # lacks keys a newer chi writes, and a key that is missing or of the
17
- # wrong shape reads as empty (nil for the optional strings and lists).
16
+ # <bundles>/<name>/manifest.json: identity, shas, files and marks.
17
+ # Reading is tolerant: an older record lacks keys a newer chi writes,
18
+ # and a key that is missing or of the wrong shape reads as empty (nil
19
+ # for the optional strings and lists). Its manifest data (requires_chi,
20
+ # trust_level, needs, providers, renderers, settings…) is read through
21
+ # #manifest, never off the record.
18
22
  #
19
23
  # files file key ("x.md") => { checksum:, conflict:, incoming_sha:, legacy_base:, why:, missed:, missed_places:,
20
24
  # description: } (symbol keys);
@@ -26,32 +30,36 @@ module Samagotchi
26
30
  # guardrails basename => recorded sha256 ("" when none)
27
31
  # scripts file name => recorded sha256 ("" when none)
28
32
  # plugin PluginRef, nil without one
29
- # needs, context_providers, block_renderers
30
- # the lists as recorded (nil when absent): Manifest.parse_needs,
31
- # ContextProviders.parse_list and BlockRenderers.parse_list check them
32
33
  # includes a profile's recorded members, nil for a plain bundle
33
- # session_settings
34
- # the keys a session may set (chi --set), [] when none
35
- # session_setting_types
36
- # {key => type} for the typed ones (Manifest::SESSION_SETTING_TYPES), {} when none
37
- # settings the keys config.yml bundles: <name>: may have, nil when the
38
- # manifest says nothing of them (BundleSettingsCheck)
39
- # setting_types
40
- # {key => type} for the typed ones, {} when none
34
+ # mirror the record's frozen copy of the manifest keys (MIRROR_KEYS,
35
+ # string keys, the values as recorded), which only
36
+ # InstalledManifest reads: requires_chi, trust_level, needs,
37
+ # context_providers, block_renderers, session_settings,
38
+ # session_setting_types, settings, setting_types. A key a
39
+ # later manifest gains is never mirrored
41
40
  # dev a dev install (`chi bundle install --dev`): its source is
42
41
  # the folder it links to and its files carry Provenance::DEV_SHA
42
+ # manifest_sha the kept manifest.yml's recorded sha256 (Provenance::DEV_SHA
43
+ # for a dev install's link), nil on a record written before
44
+ # chi kept it, or rewritten since by a chi that doesn't
45
+ # installed_by the chi VERSION that wrote the record with its kept
46
+ # manifest, nil when manifest_sha is
47
+ # dir its .bundles/<name> folder (.read, Provenance.each_installed),
48
+ # nil for a record parsed alone or one that doesn't read
43
49
  # error why manifest.json couldn't be read (the rest empty), else nil
44
- class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
45
- :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
46
- :includes, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings,
47
- :setting_types, :dev,
48
- :error)
49
- OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
50
- requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
51
- includes: nil, context_providers: nil, block_renderers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
52
- error: nil }.freeze
50
+ class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :source_commit, :files, :hooks,
51
+ :guardrails, :scripts, :plugin, :includes, :dev, :mirror, :manifest_sha,
52
+ :installed_by, :dir, :error)
53
+ OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, source_commit: nil, files: {}, hooks: {},
54
+ guardrails: {}, scripts: {}, plugin: nil, includes: nil, dev: false, mirror: {}, manifest_sha: nil,
55
+ installed_by: nil, dir: nil, error: nil }.freeze
56
+
57
+ # The manifest keys a record mirrors, as Provenance#write records them.
58
+ MIRROR_KEYS = %w[requires_chi trust_level needs context_providers block_renderers session_settings
59
+ session_setting_types settings setting_types].freeze
53
60
 
54
61
  def initialize(name:, **fields)
62
+ fields[:mirror] = fields[:mirror].to_h.transform_keys(&:to_s).slice(*MIRROR_KEYS).freeze if fields.key?(:mirror)
55
63
  super(name: name, **OPTIONAL, **fields)
56
64
  end
57
65
 
@@ -66,7 +74,7 @@ module Samagotchi
66
74
  def self.read(name, path)
67
75
  return nil unless File.exist?(path)
68
76
 
69
- parse(name, JSON.parse(File.read(path)))
77
+ parse(name, JSON.parse(File.read(path)), File.dirname(path))
70
78
  end
71
79
 
72
80
  # One that couldn't be read: +message+ says why.
@@ -74,25 +82,21 @@ module Samagotchi
74
82
 
75
83
  # @param raw [Hash] the parsed manifest.json (string or symbol keys);
76
84
  # anything else reads as unreadable
77
- def self.parse(name, raw)
85
+ # @param dir [String, nil] its .bundles/<name> folder
86
+ def self.parse(name, raw, dir = nil)
78
87
  return unreadable(name, "manifest.json is not an object") unless raw.is_a?(Hash)
79
88
 
80
89
  h = raw.transform_keys(&:to_s)
81
90
  new(name: name, version: string(h["version"]), scope: string(h["scope"]), source: string(h["source"]),
82
- installed_at: string(h["installed_at"]), trust_level: string(h["trust_level"]),
83
- source_commit: string(h["source_commit"]), requires_chi: string(h["requires_chi"]),
91
+ installed_at: string(h["installed_at"]), source_commit: string(h["source_commit"]),
84
92
  files: mapping(h["files"]) { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : {} },
85
93
  hooks: mapping(h["hooks"]) { |meta| BundleHook.parse(meta) },
86
94
  guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
87
95
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
88
- plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
89
- block_renderers: h["block_renderers"],
96
+ plugin: PluginRef.parse(h["plugin"]),
90
97
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
91
- session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
92
- session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
93
- settings: h["settings"].is_a?(Array) ? h["settings"].map(&:to_s) : nil,
94
- setting_types: mapping(h["setting_types"]) { |type| type.to_s },
95
- dev: h["dev"] == true)
98
+ dev: h["dev"] == true, mirror: h.slice(*MIRROR_KEYS).compact,
99
+ manifest_sha: string(h["manifest_sha"]), installed_by: string(h["installed_by"]), dir: dir)
96
100
  end
97
101
 
98
102
  def self.string(value) = value&.to_s
@@ -106,12 +110,24 @@ module Samagotchi
106
110
 
107
111
  def error? = !error.nil?
108
112
 
113
+ # Its kept manifest.yml (Provenance#kept_manifest_path), nil with no dir.
114
+ def kept_manifest_path = dir && File.join(dir, "manifest.yml")
115
+
116
+ # Whether it lacks a kept manifest.yml chi can read (Provenance#adopt_manifest
117
+ # keeps one): an older chi's record has no manifest_sha (with or without a
118
+ # file there, InstalledManifest's legacy and :stale), or the file it
119
+ # recorded is gone (:missing). false with no dir.
120
+ def manifest_unkept? = !dir.nil? && (manifest_sha.to_s.empty? || !File.file?(kept_manifest_path))
121
+
122
+ # What its manifest says (requires_chi, hook metadata, needs, providers,
123
+ # renderers, settings…): the one way to read manifest data of an
124
+ # installed bundle.
125
+ # @return [InstalledManifest]
126
+ def manifest = InstalledManifest.of(self)
127
+
109
128
  # The scope its files went to: "system" when none is recorded.
110
129
  def effective_scope = scope.to_s.strip.empty? ? "system" : scope
111
130
 
112
- # A bundle with no trust_level counts as experimental.
113
- def experimental? = (trust_level || "experimental") == "experimental"
114
-
115
131
  # A profile (meta bundle): it records its members.
116
132
  def profile? = !includes.nil?
117
133
 
@@ -155,18 +171,11 @@ module Samagotchi
155
171
  end
156
172
 
157
173
  def source_sha(key)
158
- path = File.join(source.to_s, key)
159
- File.file?(path) ? Digest::SHA256.file(path).hexdigest : nil
174
+ FileDigest.hex(File.join(source.to_s, key))
160
175
  rescue SystemCallError
161
176
  nil
162
177
  end
163
178
  private :source_sha
164
-
165
- def needs? = needs.is_a?(Array) && !needs.empty?
166
-
167
- def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
168
-
169
- def block_renderers? = block_renderers.is_a?(Array) && !block_renderers.empty?
170
179
  end
171
180
  end
172
181
  end
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "json"
5
4
  require "tmpdir"
5
+ require "yaml"
6
6
  require_relative "bundle_hook"
7
7
  require_relative "manifest"
8
8
  require_relative "provenance"
9
9
 
10
10
  module Samagotchi
11
11
  module MemoryBundle
12
- # An installed bundle laid out as a source folder again: manifest.yml from
13
- # its record, the plugin, hooks, rules and scripts as they are installed
14
- # (a dev install's links followed), the memories as their bases (the
15
- # bundle's bytes, not the user's edits). A bundle's source may be gone or
16
- # be a URL; this is what `chi update` can check of what really runs.
12
+ # An installed bundle laid out as a source folder again: the plugin, hooks,
13
+ # rules and scripts as they are installed (a dev install's links
14
+ # followed), the memories as their bases (the bundle's bytes, not the
15
+ # user's edits). A bundle's source may be gone or be a URL; this is what
16
+ # `chi update` can check of what really runs.
17
+ #
18
+ # manifest.yml: its files:, hooks:, plugin: and scripts: are the record's
19
+ # and the copy's (what is installed: a memory the install left out, or one
20
+ # with no base, and a manifest hook the record lacks aren't listed, so the
21
+ # check finds no missing-file for them). Every other key is the kept
22
+ # manifest.yml's when it is read (InstalledManifest origin :kept), so the
23
+ # check sees its unknown keys too; else the record's mirror's.
17
24
  #
18
25
  # The shas are the files' own, so an edited plugin is checked as written.
19
26
  # A plugin, hook or script the record lists that isn't there stays
@@ -33,23 +40,38 @@ module Samagotchi
33
40
  end
34
41
 
35
42
  def write(bundle, provenance, dir)
43
+ manifest = bundle.manifest
36
44
  Manifest.write(
37
- dir: dir, name: bundle.name, version: bundle.version.to_s.empty? ? "0" : bundle.version, trust_level: bundle.trust_level,
45
+ dir: dir, name: bundle.name, version: bundle.version.to_s.empty? ? "0" : bundle.version, trust_level: manifest.trust_level,
38
46
  files: memories(bundle, provenance, dir),
39
- file_descriptions: bundle.files.to_h { |key, entry| [key, entry[:description].to_s] },
47
+ file_descriptions: manifest.file_descriptions,
40
48
  hooks: hooks(bundle, provenance, dir), plugin: plugin(bundle, provenance, dir),
41
49
  scripts: copies(bundle.scripts, provenance.scripts_dir, File.join(dir, "scripts")),
42
- requires_chi: bundle.requires_chi, needs: bundle.needs,
43
- # A record read with symbol keys: back to what a manifest.yml holds.
44
- context_providers: JSON.parse(JSON.generate(Array(bundle.context_providers))),
45
- block_renderers: JSON.parse(JSON.generate(Array(bundle.block_renderers))),
46
- session_settings: bundle.session_settings, session_setting_types: bundle.session_setting_types,
47
- settings: bundle.settings, setting_types: bundle.setting_types
50
+ requires_chi: manifest.requires_chi, needs: manifest.needs,
51
+ context_providers: manifest.context_providers, block_renderers: manifest.block_renderers,
52
+ session_settings: manifest.session_settings, session_setting_types: manifest.session_setting_types,
53
+ settings: manifest.settings, setting_types: manifest.setting_types
48
54
  )
55
+ keep_manifest_keys(bundle.kept_manifest_path, File.join(dir, "manifest.yml"), manifest.key_problems.keys) if manifest.origin == :kept
49
56
  copies(bundle.guardrails, provenance.guardrails_dir, File.join(dir, "guardrails"))
50
57
  end
51
58
  private_class_method :write
52
59
 
60
+ # What is installed, which the copy's manifest.yml takes from the record.
61
+ INSTALLED_KEYS = %w[name version files hooks plugin scripts].freeze
62
+
63
+ # The written manifest's INSTALLED_KEYS and +failed+ keys (the value
64
+ # InstalledManifest fell back to: InstalledCheck already names the
65
+ # problem, and the raw value would name it again), then the kept
66
+ # manifest's other keys. A kept file of comments only holds none.
67
+ def keep_manifest_keys(kept, written, failed)
68
+ ours = YAML.load_file(written)
69
+ theirs = YAML.load_file(kept) || {}
70
+ own = INSTALLED_KEYS + failed
71
+ File.write(written, YAML.dump(ours.slice(*own).merge(theirs.except(*own))))
72
+ end
73
+ private_class_method :keep_manifest_keys
74
+
53
75
  # A memory with no base has nothing to install, so it isn't listed.
54
76
  def memories(bundle, provenance, dir)
55
77
  bundle.files.keys.select { |key| File.file?(provenance.base_path(key)) }.to_h do |key|
@@ -58,8 +80,9 @@ module Samagotchi
58
80
  end
59
81
  private_class_method :memories
60
82
 
83
+ # The recorded hooks with the metadata they load with (InstalledManifest#hooks).
61
84
  def hooks(bundle, provenance, dir)
62
- bundle.hooks.to_h do |file, hook|
85
+ bundle.manifest.hooks.to_h do |file, hook|
63
86
  sha = copy(File.join(provenance.hooks_dir, file), File.join(dir, "hooks", file), hook.sha256)
64
87
  [file, hook.with(sha256: sha)]
65
88
  end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_providers"
4
+ require_relative "../version"
5
+ require_relative "manifest"
6
+ require_relative "provenance"
7
+
8
+ module Samagotchi
9
+ autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
10
+
11
+ module MemoryBundle
12
+ # The one answer to "this installed bundle's manifest": what its
13
+ # manifest.yml says, as the readers of an installed bundle take it
14
+ # (InstalledBundle#manifest). Identity and state stay on the record
15
+ # (InstalledBundle: name, version, source, the plugin's file, every
16
+ # sha, files, marks, a profile's members); manifest data is read here.
17
+ #
18
+ # Read from the bundle's kept manifest.yml (.bundles/<name>/manifest.yml,
19
+ # Provenance#write's manifest_from:) when the record has its sha and the
20
+ # file still has it (a dev install's link is never checked): origin
21
+ # :kept, so a key the record never mirrored (one an older chi didn't
22
+ # know) is read. Otherwise from the record's frozen mirror of the
23
+ # manifest keys (origin :record), in four cases:
24
+ # no kept file, no manifest_sha a record from before chi kept it; no problem
25
+ # no kept file, a manifest_sha problems["manifest.yml"] = :missing (it was
26
+ # removed, or something else is in its place)
27
+ # kept, no manifest_sha problems["manifest.yml"] = :stale (a chi
28
+ # without the kept manifest rewrote the record;
29
+ # the file may be an earlier version's)
30
+ # kept, another sha, or not a problems["manifest.yml"] = :edited
31
+ # YAML mapping
32
+ # kept, its sha origin :kept
33
+ # Each key is parsed on its own (Manifest.tolerant for the kept file): one
34
+ # that doesn't parse reads as the mirror's value if that parses, else as
35
+ # empty, and its message is in +problems+ (key => message), so one bad key
36
+ # never costs the rest. A reader that logged such a key logs problems[key].
37
+ # Built once per record and kept file (a process-wide cache keyed by the
38
+ # record and the file's stat): chi web asks per request.
39
+ #
40
+ # requires_chi, trust_level the strings as recorded, nil when none
41
+ # description the kept manifest's, nil from the record
42
+ # hooks basename => BundleHook: each recorded hook,
43
+ # the kept manifest's events, on_error and
44
+ # priority with the record's sha
45
+ # needs Manifest.parse_needs'
46
+ # script_names the scripts/ files it lists
47
+ # context_providers Array<ContextProviders::Provider>
48
+ # block_renderers Array<BlockRenderers::Renderer>
49
+ # session_settings, session_setting_types, settings, setting_types
50
+ # as Manifest reads them (settings nil when
51
+ # the manifest said nothing of them)
52
+ # file_descriptions file key => the memory's description
53
+ # includes a profile's recorded members (the record's
54
+ # identity, never the kept file's), nil for a plain bundle
55
+ # unknown_keys the kept manifest's keys this chi doesn't know
56
+ # floor_keys the kept manifest's Manifest#floor_keys (key =>
57
+ # the first chi that read it), {} from the record
58
+ # origin :kept or :record
59
+ class InstalledManifest < Data.define(:name, :version, :requires_chi, :trust_level, :description, :hooks, :needs,
60
+ :script_names, :context_providers, :block_renderers, :session_settings,
61
+ :session_setting_types, :settings, :setting_types, :file_descriptions,
62
+ :includes, :unknown_keys, :floor_keys, :problems, :origin)
63
+ # The key problems names the kept file's standing under.
64
+ KEPT_FILE = "manifest.yml"
65
+ # How many manifests the cache holds before it starts again.
66
+ CACHE_LIMIT = 256
67
+ @cache = {}
68
+ @cache_lock = Mutex.new
69
+
70
+ # @param bundle [InstalledBundle]
71
+ # @return [InstalledManifest]
72
+ def self.of(bundle)
73
+ key = [bundle, kept_stat(bundle.kept_manifest_path)]
74
+ cached = @cache_lock.synchronize { @cache[key] }
75
+ return cached if cached
76
+
77
+ built = deep_freeze(build(bundle))
78
+ @cache_lock.synchronize do
79
+ @cache.clear if @cache.size >= CACHE_LIMIT
80
+ @cache[key] = built
81
+ end
82
+ end
83
+
84
+ # Callers share the cached value, so nothing in it can change under another.
85
+ def self.deep_freeze(value)
86
+ case value
87
+ when Hash then value.each { |key, item| deep_freeze(key) && deep_freeze(item) }
88
+ when Array then value.each { |item| deep_freeze(item) }
89
+ when Data then value.to_h.each_value { |item| deep_freeze(item) }
90
+ end
91
+ value.freeze
92
+ end
93
+
94
+ # What says the kept file changed: nil when there is none.
95
+ def self.kept_stat(path)
96
+ stat = path && File.stat(path)
97
+ stat && [stat.mtime.to_r, stat.size, stat.ino, stat.dev]
98
+ rescue SystemCallError
99
+ nil
100
+ end
101
+
102
+ def self.build(bundle)
103
+ mirrored = from_record(bundle)
104
+ path = bundle.kept_manifest_path
105
+ return mirrored unless path
106
+
107
+ recorded = !bundle.manifest_sha.to_s.empty?
108
+ return recorded ? standing(mirrored, :missing) : mirrored unless File.file?(path)
109
+ return standing(mirrored, :stale) unless recorded
110
+ return standing(mirrored, :edited) unless Provenance.sha_matches?(path, bundle.manifest_sha)
111
+
112
+ kept, problems = Manifest.tolerant(path: path, fallback_scripts: bundle.scripts.keys)
113
+ from_kept(bundle, kept, problems, mirrored)
114
+ rescue Manifest::ValidationError, SystemCallError, EncodingError
115
+ standing(mirrored, :edited)
116
+ end
117
+
118
+ def self.standing(mirrored, why) = mirrored.with(problems: mirrored.problems.merge(KEPT_FILE => why).freeze)
119
+
120
+ # The kept manifest's values; a key that failed takes the mirror's.
121
+ def self.from_kept(bundle, kept, problems, mirrored)
122
+ pick = ->(key, ours, theirs) { problems.key?(key) ? theirs : ours }
123
+ session = pick.call("session_settings", kept, mirrored)
124
+ settings = pick.call("settings", kept, mirrored)
125
+ new(name: bundle.name, version: bundle.version,
126
+ requires_chi: pick.call("requires_chi", kept.requires_chi, mirrored.requires_chi),
127
+ trust_level: kept.trust_level, description: kept.description.empty? ? nil : kept.description,
128
+ hooks: pick.call("hooks", kept_hooks(bundle.hooks, kept.hooks), bundle.hooks),
129
+ needs: pick.call("needs", kept.needs, mirrored.needs),
130
+ script_names: pick.call("scripts", kept.scripts.keys, mirrored.script_names),
131
+ context_providers: pick.call("context_providers", kept.context_providers, mirrored.context_providers),
132
+ block_renderers: pick.call("block_renderers", kept.block_renderers, mirrored.block_renderers),
133
+ session_settings: session.session_settings, session_setting_types: session.session_setting_types,
134
+ settings: settings.settings, setting_types: settings.setting_types,
135
+ file_descriptions: pick.call("files", kept.file_descriptions, mirrored.file_descriptions),
136
+ includes: bundle.includes,
137
+ unknown_keys: kept.unknown_keys.map(&:to_s), floor_keys: floors(kept), problems: problems.freeze, origin: :kept)
138
+ end
139
+
140
+ # Manifest#floor_keys of the tolerant read; a shape it can't tell is no floor.
141
+ def self.floors(kept)
142
+ kept.floor_keys
143
+ rescue StandardError
144
+ {}
145
+ end
146
+
147
+ # Each recorded hook (what is installed): the kept manifest's metadata
148
+ # for it with the record's sha; one the manifest doesn't list (a found
149
+ # hooks/*.rb) as recorded. A manifest hook the record lacks isn't installed.
150
+ def self.kept_hooks(recorded, declared)
151
+ recorded.to_h do |file, hook|
152
+ [file, declared.key?(file) ? declared[file].with(sha256: hook.sha256) : hook]
153
+ end
154
+ end
155
+
156
+ def self.from_record(bundle)
157
+ raw = recorded(bundle)
158
+ problems = {}
159
+ new(name: bundle.name, version: bundle.version,
160
+ requires_chi: raw["requires_chi"]&.to_s, trust_level: raw["trust_level"]&.to_s, description: nil,
161
+ hooks: bundle.hooks,
162
+ needs: parsed(problems, "needs", [], Manifest::ValidationError) { Manifest.parse_needs(raw["needs"]) },
163
+ script_names: bundle.scripts.keys,
164
+ context_providers: parsed(problems, "context_providers", []) { ContextProviders.parse_list(raw["context_providers"]) },
165
+ block_renderers: parsed(problems, "block_renderers", []) { BlockRenderers.parse_list(raw["block_renderers"]) },
166
+ session_settings: list(raw["session_settings"]) || [],
167
+ session_setting_types: types(raw["session_setting_types"]),
168
+ settings: list(raw["settings"]),
169
+ setting_types: types(raw["setting_types"]),
170
+ file_descriptions: file_descriptions(bundle.files),
171
+ includes: bundle.includes, unknown_keys: [], floor_keys: {}, problems: problems.freeze, origin: :record)
172
+ end
173
+
174
+ # The record's values of the manifest keys (its frozen mirror), string
175
+ # keys: what a bundle with no usable kept manifest, or no dir (a record
176
+ # parsed alone), reads.
177
+ def self.recorded(bundle) = bundle.mirror
178
+
179
+ # The block's value, else +empty+ with the key's problem noted.
180
+ # ArgumentError: ContextProviders::Invalid and BlockRenderers::Invalid
181
+ # are ones; EncodingError: a record's string whose bytes aren't UTF-8
182
+ # (JSON keeps them) raises Encoding::CompatibilityError when parsed.
183
+ def self.parsed(problems, key, empty, *errors)
184
+ yield
185
+ rescue ArgumentError, EncodingError, *errors => e
186
+ problems[key] = e.message
187
+ empty
188
+ end
189
+
190
+ def self.list(value) = value.is_a?(Array) ? value.map(&:to_s) : nil
191
+
192
+ def self.types(value) = value.is_a?(Hash) ? value.to_h { |key, type| [key.to_s, type.to_s] } : {}
193
+
194
+ def self.file_descriptions(files)
195
+ files.each_with_object({}) do |(key, entry), acc|
196
+ text = entry[:description].to_s
197
+ acc[key] = text unless text.empty?
198
+ end
199
+ end
200
+ private_class_method :deep_freeze, :kept_stat, :build, :standing, :from_kept, :floors, :kept_hooks, :from_record, :recorded, :parsed,
201
+ :list, :types, :file_descriptions
202
+
203
+ # Why the kept manifest.yml wasn't read: :missing, :stale or :edited (the class
204
+ # comment's cases), nil when it was or there is none.
205
+ def standing = problems[KEPT_FILE]
206
+
207
+ # The keys that didn't parse, key => message, without the kept file's standing.
208
+ def key_problems = problems.except(KEPT_FILE)
209
+
210
+ # Why chi +version+ can't load this bundle, nil when it can (or it
211
+ # names no requirement): the one requires_chi gate.
212
+ def requires_chi_failure(version = Samagotchi::VERSION) = Manifest.requires_chi_failure(requires_chi, version)
213
+
214
+ # A bundle with no trust_level counts as experimental.
215
+ def experimental? = (trust_level || "experimental") == "experimental"
216
+
217
+ def needs? = !needs.empty?
218
+
219
+ def context_providers? = !context_providers.empty?
220
+
221
+ def block_renderers? = !block_renderers.empty?
222
+ end
223
+ end
224
+ end