samagotchi 0.156.0 → 0.157.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/bridge.md +83 -6
  11. data/docs/internals/bundles.md +28 -18
  12. data/docs/internals/context-telemetry.md +4 -3
  13. data/docs/internals/engine.md +16 -2
  14. data/docs/internals/participants.md +3 -2
  15. data/docs/internals/remote-sessions.md +5 -2
  16. data/docs/internals/web-frontend.md +3 -2
  17. data/docs/internals/working-set.md +1 -1
  18. data/docs/memory.md +13 -2
  19. data/docs/plugins.md +35 -2
  20. data/docs/sessions.md +9 -9
  21. data/docs/sub-agent.md +17 -2
  22. data/docs/testing.md +53 -2
  23. data/lib/samagotchi/answer_command.rb +2 -1
  24. data/lib/samagotchi/archive_store.rb +4 -3
  25. data/lib/samagotchi/bootstrap_command.rb +3 -2
  26. data/lib/samagotchi/bridge/card_places.rb +10 -4
  27. data/lib/samagotchi/bridge/card_store.rb +3 -0
  28. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  29. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  30. data/lib/samagotchi/bridge.rb +31 -6
  31. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  32. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  33. data/lib/samagotchi/bundle_command/check.rb +18 -0
  34. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  35. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  36. data/lib/samagotchi/bundle_command.rb +5 -2
  37. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  38. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  39. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  40. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  41. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  42. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  43. data/lib/samagotchi/child_move.rb +6 -6
  44. data/lib/samagotchi/child_reports.rb +2 -2
  45. data/lib/samagotchi/children_status.rb +15 -21
  46. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  47. data/lib/samagotchi/config.rb +3 -0
  48. data/lib/samagotchi/context_sources.rb +5 -4
  49. data/lib/samagotchi/context_window.rb +9 -12
  50. data/lib/samagotchi/effective_model.rb +57 -22
  51. data/lib/samagotchi/engine/plugins.rb +1 -1
  52. data/lib/samagotchi/engine/recap.rb +8 -4
  53. data/lib/samagotchi/engine.rb +29 -3
  54. data/lib/samagotchi/events.rb +3 -2
  55. data/lib/samagotchi/extension_load.rb +6 -3
  56. data/lib/samagotchi/guardrails_command.rb +1 -1
  57. data/lib/samagotchi/host_address.rb +52 -0
  58. data/lib/samagotchi/host_registry.rb +57 -45
  59. data/lib/samagotchi/kernel_loop.rb +7 -3
  60. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  61. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  62. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  63. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  64. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  65. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  66. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  67. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  68. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  69. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  70. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  71. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  72. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  73. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  74. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  75. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  76. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  77. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  78. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  79. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  80. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  81. data/lib/samagotchi/memory_usage.rb +1 -1
  82. data/lib/samagotchi/model_lookup.rb +75 -0
  83. data/lib/samagotchi/model_profile.rb +1 -0
  84. data/lib/samagotchi/models_command.rb +1 -1
  85. data/lib/samagotchi/note_command.rb +1 -1
  86. data/lib/samagotchi/participant_mode.rb +2 -1
  87. data/lib/samagotchi/plugin/api.rb +41 -6
  88. data/lib/samagotchi/plugin/loader.rb +33 -10
  89. data/lib/samagotchi/plugin/sessions.rb +1 -1
  90. data/lib/samagotchi/reasoning_history.rb +14 -20
  91. data/lib/samagotchi/recap_place.rb +27 -0
  92. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  93. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  94. data/lib/samagotchi/reply_wait/target.rb +113 -0
  95. data/lib/samagotchi/reply_wait.rb +22 -2
  96. data/lib/samagotchi/sampling_settings.rb +9 -16
  97. data/lib/samagotchi/self_report.rb +34 -35
  98. data/lib/samagotchi/send_command.rb +17 -23
  99. data/lib/samagotchi/session.rb +321 -439
  100. data/lib/samagotchi/session_access/local.rb +5 -6
  101. data/lib/samagotchi/session_access/remote.rb +5 -3
  102. data/lib/samagotchi/session_field.rb +133 -0
  103. data/lib/samagotchi/session_inbox.rb +49 -1
  104. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  105. data/lib/samagotchi/session_manager/listing.rb +29 -100
  106. data/lib/samagotchi/session_manager.rb +2 -1
  107. data/lib/samagotchi/session_metrics.rb +7 -4
  108. data/lib/samagotchi/session_paths.rb +57 -0
  109. data/lib/samagotchi/session_retention.rb +2 -2
  110. data/lib/samagotchi/session_row.rb +200 -0
  111. data/lib/samagotchi/session_setup.rb +15 -12
  112. data/lib/samagotchi/sessions_command.rb +11 -129
  113. data/lib/samagotchi/steer_cut_after.rb +7 -14
  114. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  115. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  116. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  117. data/lib/samagotchi/terminal_ui.rb +3 -1
  118. data/lib/samagotchi/testing/install.rb +2 -1
  119. data/lib/samagotchi/thinking.rb +16 -21
  120. data/lib/samagotchi/tool_response.rb +5 -3
  121. data/lib/samagotchi/tool_runner.rb +18 -1
  122. data/lib/samagotchi/tools/delegate.rb +48 -43
  123. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  124. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  125. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  126. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  127. data/lib/samagotchi/update_command.rb +11 -7
  128. data/lib/samagotchi/verify_fresh.rb +7 -11
  129. data/lib/samagotchi/version.rb +1 -1
  130. data/lib/samagotchi/vision_support.rb +11 -14
  131. data/lib/samagotchi/web/app.rb +31 -21
  132. data/lib/samagotchi/web/message_display.rb +57 -7
  133. data/lib/samagotchi/web/participant_access.rb +1 -1
  134. data/lib/samagotchi/web/public/activity.js +16 -7
  135. data/lib/samagotchi/web/public/app.js +29 -8
  136. data/lib/samagotchi/web/public/bubbles.js +8 -3
  137. data/lib/samagotchi/web/public/cards_place.js +15 -0
  138. data/lib/samagotchi/web/public/commands.js +5 -1
  139. data/lib/samagotchi/web/public/data.js +4 -2
  140. data/lib/samagotchi/web/public/history_shape.js +220 -0
  141. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  142. data/lib/samagotchi/web/public/reload_check.js +71 -0
  143. data/lib/samagotchi/web/public/stage_model.js +7 -5
  144. data/lib/samagotchi/web/public/stage_view.js +7 -3
  145. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  146. data/lib/samagotchi/web/public/timing.js +43 -2
  147. data/lib/samagotchi/web/public/turn_block.js +215 -30
  148. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  149. data/lib/samagotchi/web/public/turn_events.js +10 -1
  150. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  151. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  152. data/lib/samagotchi/web/server.rb +4 -2
  153. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  154. data/lib/samagotchi/web/session_control.rb +1 -1
  155. data/lib/samagotchi/web/session_hub.rb +9 -17
  156. data/lib/samagotchi/web/session_show.rb +64 -2
  157. data/lib/samagotchi/web/session_summary.rb +36 -24
  158. data/lib/samagotchi/worker.rb +28 -5
  159. data/lib/samagotchi/worker_link.rb +3 -0
  160. metadata +20 -4
  161. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -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
 
@@ -159,6 +159,16 @@ module Samagotchi
159
159
  unchecked?(recorded) || file_sha(path) == recorded_sha(recorded)
160
160
  end
161
161
 
162
+ # Whether a memory on disk is what the install wrote: the entry's
163
+ # recorded checksum, else its base copy. Unknown counts as edited. The
164
+ # one rule a prune, an uninstall's refusal and chi bundle status read.
165
+ def self.unchanged?(path, entry, base_path)
166
+ recorded = entry[:checksum]
167
+ return sha_matches?(path, recorded) unless recorded_sha(recorded).empty?
168
+
169
+ File.exist?(base_path) && file_sha(path) == file_sha(base_path)
170
+ end
171
+
162
172
  # The record's mirror of the manifest keys (InstalledBundle::MIRROR_KEYS)
163
173
  # as #write records them from its kwargs: what an older chi reads, and
164
174
  # what #adopt_manifest holds a source's manifest against.
@@ -191,6 +201,9 @@ module Samagotchi
191
201
  # @param plugin_file [String, nil] the installed plugin file; its name
192
202
  # and sha256 are recorded (the Engine checks it at load) with a base
193
203
  # snapshot
204
+ # @param plugin_required [Boolean] the manifest's plugin: required: true
205
+ # (a guardrail plugin), recorded with it as "required": true, so the
206
+ # Engine knows it whatever becomes of the kept manifest.yml
194
207
  # @param requires_chi [String, nil] the manifest's requirement
195
208
  # @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
196
209
  # @param marks [Array<FileStanding::Behind>] files an upgrade left behind;
@@ -217,7 +230,7 @@ module Samagotchi
217
230
  # an older chi sharing this memories dir reads only them
218
231
  # (docs/internals/bundles.md); a key a later manifest gains is never added
219
232
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
220
- guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
233
+ guardrails_files: {}, plugin_file: nil, plugin_required: false, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
221
234
  scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
222
235
  dev: false, manifest_from: nil)
223
236
  FileUtils.mkdir_p(@bundle_dir)
@@ -318,6 +331,7 @@ module Samagotchi
318
331
  content = File.binread(plugin_file.to_s) # the base snapshot's bytes
319
332
  manifest_data["plugin"] = { "file" => File.basename(plugin_file.to_s),
320
333
  "sha256" => dev ? DEV_SHA : FileDigest.sha256(plugin_file.to_s) }
334
+ manifest_data["plugin"]["required"] = true if plugin_required
321
335
  FileUtils.mkdir_p(File.join(bases_dir, "plugin"))
322
336
  File.binwrite(plugin_base_path(File.basename(plugin_file.to_s)), content)
323
337
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "index_updater"
4
4
  require_relative "manifest"
5
5
  require_relative "memory_files"
6
+ require_relative "file_actions"
6
7
  require_relative "merger"
7
8
  require_relative "provenance"
8
9
  require_relative "trash"
@@ -17,7 +18,8 @@ module Samagotchi
17
18
  # scripts, and GuardrailWiring#rules stats per tool call, so a live session
18
19
  # could hit a transient deny.
19
20
  #
20
- # Each marked file, by what Merger::Upgrade says of it:
21
+ # Each marked file, by its FileState and REMERGE row (FileActions; a
22
+ # :decide cell by what Merger::Upgrade says of it):
21
23
  # - not shipped now: its mark, base and text stay, and it reads as behind
22
24
  # unless level or missing, as every report has it;
23
25
  # - the shipped text isn't the one the mark names (a checkout at the same
@@ -32,7 +34,7 @@ module Samagotchi
32
34
  # to miss, so the mark goes;
33
35
  # - conflict: behind, and the mark records why, only when that changed;
34
36
  # - fast_forward (one Merger::Upgrade#outcome lets go ahead): the next
35
- # upgrade's (save_marked_file), behind until then;
37
+ # upgrade's (its save_first), behind until then;
36
38
  # - install: a missing file, which isn't behind (FileStanding.behind?): the
37
39
  # next upgrade or a restore puts it back, and a warning says so;
38
40
  # - one it can't read or replace: left as it is, behind, saying why.
@@ -90,24 +92,35 @@ module Samagotchi
90
92
  return
91
93
  end
92
94
 
93
- incoming_sha = Merger.digest(view.paths(key)[:incoming])
94
- if entry[:incoming_sha] && entry[:incoming_sha] != incoming_sha
95
+ state = view.state(key)
96
+ if state.source_moved?
95
97
  @behind << @record.behind_entry(key, entry) if behind
96
98
  return
97
99
  end
98
100
 
99
- outcome = view.outcome(key)
100
- case outcome.status
101
- when :merged then merge_file(key, entry, outcome)
102
- when :conflict then mark(key, entry, view.behind(key, outcome, @record.version))
103
- when :noop, :keep then @settled << key
104
- when :fast_forward then mark(key, entry, behind_for(key, FileStanding::WAITS_FOR_UPGRADE))
105
- when :install then @warnings << "#{key}: #{FileStanding::FILE_MISSING}"
106
- end
101
+ retry_row(key, entry, state, FileActions.for(:remerge, state))
107
102
  rescue SystemCallError => e
108
103
  stay(key, FileStanding.left_as_it_is(e.message))
109
104
  end
110
105
 
106
+ # The file's REMERGE row, done. A :decide cell, and a behind one, asks
107
+ # Merger::Upgrade#decision for the Outcome (the text merge, the legacy
108
+ # guard, or why no merge is possible); a fast-forward waits for the
109
+ # upgrade. A missing file is put back by the next upgrade or a restore;
110
+ # one it can't read raises here (left as it is, saying why).
111
+ def retry_row(key, entry, state, action)
112
+ decision = view.decision(key, state) if action.do == :decide || action.behind?
113
+ action = FileActions.decided(decision.outcome, :remerge) if action.do == :decide
114
+ if action.do == :merge then merge_file(key, entry, decision.outcome)
115
+ elsif action.behind?
116
+ waits = decision.outcome.status == :fast_forward
117
+ mark(key, entry, waits ? behind_for(key, FileStanding::WAITS_FOR_UPGRADE) : view.behind(key, decision.outcome, @record.version))
118
+ elsif action.word == :settled then @settled << key
119
+ elsif state.kind == :missing then @warnings << "#{key}: #{FileStanding::FILE_MISSING}"
120
+ elsif state.kind == :unreadable then FileDigest.hex(state.paths[:current])
121
+ end
122
+ end
123
+
111
124
  def behind_for(key, why) = FileStanding::Behind.new(file: key, version: @record.version, why: why)
112
125
 
113
126
  # The record says it too, so chi bundle status reads as the row does. There
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "asset_installer"
3
4
  require_relative "installer"
4
5
  require_relative "listing"
5
6
  require_relative "manifest"
@@ -11,6 +12,7 @@ require_relative "remerge"
11
12
  require_relative "source"
12
13
  require_relative "status"
13
14
  require_relative "system_bundle"
15
+ require_relative "trash"
14
16
  require_relative "../memory_paths"
15
17
  require_relative "../version"
16
18
 
@@ -25,9 +27,11 @@ module Samagotchi
25
27
  # bundle that isn't installed and never downgrades.
26
28
  #
27
29
  # .md files take the Installer's 3-way merge: a clean text merge replaces
28
- # the file (merged), a real conflict keeps the user's (behind). Hooks, rules and the plugin are replaced; an
29
- # edited one (which didn't load: its sha no longer matched) is listed
30
- # in replaced.
30
+ # the file (merged), a real conflict keeps the user's (behind). Hooks, rules,
31
+ # the plugin and scripts are replaced; an edited one (Trash.edit?: its
32
+ # bytes neither the recorded sha, none recorded counting as edited, nor
33
+ # the incoming file's) is copied to the trash first and listed in
34
+ # replaced (#trash_bound).
31
35
  #
32
36
  # A profile (a shipped meta bundle) is updated through Profile: its row
33
37
  # is :would_update when the shipped version is newer or it has members
@@ -41,8 +45,9 @@ module Samagotchi
41
45
  # a project-scope bundle: chi bundle upgrade in the project.
42
46
  module ShippedUpdate
43
47
  # status: :would_update, :would_merge (plan), :updated, :merged,
44
- # :up_to_date, :skipped, :failed. note says why for skipped/failed, and
45
- # what a retry at the same version warned of. merged and replaced are file
48
+ # :up_to_date, :skipped, :failed. note says why for skipped/failed,
49
+ # what a retry at the same version warned of, and an update's Checksum
50
+ # mismatch warnings. merged and replaced are file
46
51
  # names, behind FileStanding::Behind; under plan they are what apply would
47
52
  # merge, replace or leave behind.
48
53
  # adds is nil but for a profile: the members it installs.
@@ -114,6 +119,10 @@ module Samagotchi
114
119
  r.merged = installer.merged_files
115
120
  r.behind = installer.behind
116
121
  r.trash_copy = installer.trash_copy_line
122
+ # The integrity check's warnings; the rest of the Installer's say
123
+ # what the row's merged and behind already do.
124
+ mismatches = installer.warnings.grep(/\AChecksum mismatch/)
125
+ r.note = mismatches.join("; ") unless mismatches.empty?
117
126
  end
118
127
  rescue StandardError => e
119
128
  row.dup.tap do |r|
@@ -199,7 +208,7 @@ module Samagotchi
199
208
  forecast = forecast(row, bundle)
200
209
  row.merged = forecast.merged
201
210
  row.behind = forecast.behind
202
- row.replaced = edited_executables(name, bundle)
211
+ row.replaced = trash_bound(name, bundle, manifest, row.source_dir)
203
212
  row.status = :would_update
204
213
  row
205
214
  rescue SystemCallError => e
@@ -267,6 +276,42 @@ module Samagotchi
267
276
  source_dir: row.source_dir).forecast(shipped & bundle.files.keys, version: row.to)
268
277
  end
269
278
 
279
+ # What the upgrade from +source_dir+ copies to the trash, as
280
+ # hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edit? over the files
281
+ # AssetInstaller replaces or removes, each against the file it puts in
282
+ # its place. Hooks: the ones install_hooks copies (listed, else every
283
+ # hooks/*.rb; present, not links) and the ones prune_dropped_hooks
284
+ # removes (recorded, no longer shipped; no incoming). Rules, the plugin
285
+ # and scripts: their whole folder, which the upgrade replaces.
286
+ def trash_bound(name, bundle, manifest, source_dir)
287
+ provenance = Provenance.new(name: name)
288
+ rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
289
+ .reject { |file| AssetInstaller.linked?(file, source_dir) }
290
+ plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
291
+ scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
292
+ trash_bound_hooks(provenance, bundle, manifest, source_dir) +
293
+ Trash.edits(provenance.guardrails_dir, "guardrails", bundle.guardrails, rules.to_h { |file| [File.basename(file), file] }) +
294
+ Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
295
+ Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
296
+ end
297
+
298
+ # #trash_bound's hooks: AssetInstaller#install_hooks' set (incoming: the
299
+ # source's file), then #prune_dropped_hooks' (incoming: none). A stray
300
+ # file in hooks/ is in neither: the upgrade leaves it.
301
+ def trash_bound_hooks(provenance, bundle, manifest, source_dir)
302
+ src = ->(file) { File.join(source_dir, "hooks", file) }
303
+ listed = manifest.hooks.keys.select { |file| file.is_a?(String) && !file.empty? }
304
+ listed = Dir.glob(src.call("*.rb")).map { |file| File.basename(file) } if manifest.hooks.empty?
305
+ shipped = listed.reject do |file|
306
+ (File.symlink?(src.call(file)) || File.exist?(src.call(file))) && AssetInstaller.linked?(src.call(file), source_dir)
307
+ end
308
+ incoming = shipped.select { |file| File.exist?(src.call(file)) }.to_h { |file| [file, src.call(file)] }
309
+ incoming.merge!((bundle.hooks.keys - shipped).to_h { |file| [file, nil] })
310
+ incoming.filter_map do |file, from|
311
+ "hooks/#{file}" if Trash.edit?(File.join(provenance.hooks_dir, file), recorded: bundle.hooks[file]&.sha256, incoming: from)
312
+ end
313
+ end
314
+
270
315
  # Installed hooks, rules and the plugin whose sha differs from the
271
316
  # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
272
317
  def edited_executables(name, bundle)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "file_digest"
4
4
  require_relative "provenance"
5
+ require_relative "file_state"
5
6
  require_relative "manifest"
6
7
  require_relative "../version"
7
8
  require_relative "index_updater"
@@ -41,16 +42,12 @@ module Samagotchi
41
42
  files.each do |file_key_str, meta|
42
43
  next details[file_key_str] = unchecked_file(meta) unless target_dir
43
44
 
44
- target_path = File.join(target_dir, file_key_str)
45
- base_path = provenance.base_path(file_key_str)
46
- stored_checksum = meta[:checksum]
47
- current_checksum = checksum(target_path)
48
- unreadable = current_checksum == :unreadable
49
- current_checksum = nil if unreadable
50
- base_checksum = checksum(base_path)
51
- base_checksum = nil if base_checksum == :unreadable
52
- modified = base_checksum && current_checksum && base_checksum != current_checksum
53
- missing = !File.exist?(target_path)
45
+ state = FileState.of(file_key_str, record: bundle, provenance: provenance, target_dir: target_dir, source_dir: nil)
46
+ target_path = state.paths[:current]
47
+ base_path = state.paths[:base]
48
+ unreadable = state.kind == :unreadable
49
+ missing = state.kind == :missing
50
+ modified = %i[edited edited_no_base].include?(state.kind)
54
51
  # A model overlay has no index line (Installer#note_overlay).
55
52
  overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys, target_dir: target_dir)
56
53
  index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
@@ -59,9 +56,9 @@ module Samagotchi
59
56
  behind: behind[file_key_str],
60
57
  merge_note: notes[file_key_str],
61
58
  unreadable: unreadable,
62
- stored_checksum: stored_checksum,
63
- current_checksum: current_checksum,
64
- base_checksum: base_checksum,
59
+ stored_checksum: meta[:checksum],
60
+ current_checksum: digest_or_nil(state.digests.disk),
61
+ base_checksum: digest_or_nil(state.digests.base),
65
62
  modified: modified,
66
63
  missing: missing,
67
64
  index_present: index_present,
@@ -75,13 +72,10 @@ module Samagotchi
75
72
  hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
76
73
  end
77
74
 
78
- # The file's digest, nil when there is none, :unreadable when it can't be read.
79
- def self.checksum(path)
80
- FileDigest.hex(path)
81
- rescue SystemCallError
82
- :unreadable
83
- end
84
- private_class_method :checksum
75
+ # A FileState digest as the Report has it: nil for none, or one that
76
+ # can't be read (unreadable says which).
77
+ def self.digest_or_nil(digest) = digest == :unreadable ? nil : digest
78
+ private_class_method :digest_or_nil
85
79
 
86
80
  # Why none of the bundle's hooks load (this chi doesn't meet its
87
81
  # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "installer"
5
+ require_relative "file_digest"
5
6
  require_relative "provenance"
6
7
  require_relative "manifest"
7
8
  require_relative "index_updater"
@@ -204,29 +205,46 @@ module Samagotchi
204
205
  # A file from the bundle missing on disk (the user deleted it) is put
205
206
  # back, and nothing else: at the same version a checkout can carry other
206
207
  # text, so no other file is merged here (Remerge has its guard), and a live
207
- # session's guardrails, plugin and scripts stay where they are.
208
+ # session's guardrails, plugin and scripts stay where they are. Which
209
+ # missing file is put back is its RESTORE row (FileActions): one the
210
+ # record doesn't own too. A put-back file is checked against the
211
+ # manifest's files: sha, as an installed one is (a bad copy warns).
208
212
  def verify_files_present(gem_manifest, version, dry_run)
209
213
  target_dir = MemoryPaths.system_dir
210
214
  missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
215
+ missing = restored_rows(missing, target_dir) unless missing.empty?
211
216
  return result(:up_to_date, from: version, to: version) if missing.empty?
212
217
  return result(:restored, from: version, to: version) if dry_run
213
218
 
214
- warnings = missing.filter_map { |key| restore_file(key, gem_manifest.file_descriptions[key]) }
219
+ warnings = missing.flat_map { |key| restore_file(key, gem_manifest) }
215
220
  result(:restored, from: version, to: version, warnings: warnings)
216
221
  end
217
222
 
218
- # @return [String, nil] a warning: the file is in place, its index line isn't
219
- def restore_file(key, description)
223
+ # The ones among +keys+ whose FileState's RESTORE row restores.
224
+ def restored_rows(keys, target_dir)
225
+ provenance = Provenance.new(name: BUNDLE_NAME)
226
+ record = provenance.record
227
+ keys.select do |key|
228
+ state = FileState.of(key, record: record, provenance: provenance, target_dir: target_dir, source_dir: GEM_BUNDLE_DIR)
229
+ FileActions.for(:restore, state)&.do == :restore
230
+ end
231
+ end
232
+
233
+ # @return [Array<String>] warnings: its bytes don't match the manifest's
234
+ # sha, or the file is in place and its index line isn't
235
+ def restore_file(key, gem_manifest)
220
236
  source = File.join(GEM_BUNDLE_DIR, key)
221
237
  target = File.join(MemoryPaths.system_dir, key)
238
+ description = gem_manifest.file_descriptions[key]
222
239
  FileUtils.cp(source, target)
223
240
  Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
241
+ warnings = [FileDigest.mismatch(key, target, gem_manifest.checksum_for(key))].compact
224
242
  begin
225
243
  IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
226
- nil
227
244
  rescue StandardError => e
228
- "index.md: line for #{key.delete_suffix(".md")} not updated (#{e.message})"
245
+ warnings << "index.md: line for #{key.delete_suffix(".md")} not updated (#{e.message})"
229
246
  end
247
+ warnings
230
248
  end
231
249
 
232
250
  # An up-to-date bundle's marked files, retried. No marks: nothing is read.
@@ -105,11 +105,7 @@ module Samagotchi
105
105
  # +incoming+'s. A link (a dev install's) points at its source, which
106
106
  # nothing here touches.
107
107
  def copy_edit(path, as:, recorded: nil, incoming: nil)
108
- return if File.symlink?(path) || !File.file?(path)
109
- return if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
110
- return if !Provenance.recorded_sha(recorded).empty? && Provenance.sha_matches?(path, recorded)
111
-
112
- copy(path, as: as)
108
+ copy(path, as: as) if self.class.edit?(path, recorded: recorded, incoming: incoming)
113
109
  end
114
110
 
115
111
  # copy_edit for every file under +dir+, which is about to go, at any depth
@@ -117,10 +113,29 @@ module Samagotchi
117
113
  # @param recorded [Hash, nil] path in dir => its recorded sha256
118
114
  # @param incoming [Hash] path in dir => the source replacing it
119
115
  def copy_edits(dir, label, recorded = nil, incoming = {})
120
- return if File.symlink?(dir) || !File.directory?(dir)
116
+ self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
117
+ end
118
+
119
+ # Whether #copy_edit would copy the file at +path+ (read-only): the rule
120
+ # ShippedUpdate's plan names its replaced files by.
121
+ def self.edit?(path, recorded: nil, incoming: nil)
122
+ return false if File.symlink?(path) || !File.file?(path)
123
+ return false if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
124
+
125
+ Provenance.recorded_sha(recorded).empty? || !Provenance.sha_matches?(path, recorded)
126
+ end
127
+
128
+ # What #copy_edits would copy (read-only), as <label>/<its path in dir>.
129
+ def self.edits(dir, label, recorded = nil, incoming = {})
130
+ edited_in(dir, recorded, incoming).map { |file| "#{label}/#{file}" }
131
+ end
132
+
133
+ # The files under +dir+ (paths in it) #copy_edits copies.
134
+ def self.edited_in(dir, recorded, incoming)
135
+ return [] if File.symlink?(dir) || !File.directory?(dir)
121
136
 
122
- Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.each do |file|
123
- copy_edit(File.join(dir, file), as: "#{label}/#{file}", recorded: recorded&.[](file), incoming: incoming[file])
137
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.select do |file|
138
+ edit?(File.join(dir, file), recorded: recorded&.[](file), incoming: incoming[file])
124
139
  end
125
140
  end
126
141