samagotchi 0.156.0 → 0.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_state"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # What each command that acts on a bundle memory does in each FileState:
8
+ # one table per command, so a reader sees one command on one screen. A cell
9
+ # is keyed [kind, mark] or [kind, :any]; a dropped file another bundle
10
+ # lists too is [kind, :shared] (FileState#shared_with, asked only there).
11
+ # A dry run reads its command's row and doesn't execute it.
12
+ #
13
+ # A new kind or a new command adds a row in each table it reaches
14
+ # (docs/internals/bundles.md).
15
+ module FileActions
16
+ # do: what happens to the file's bytes
17
+ # :write_incoming the bundle's text replaces it (or is put there)
18
+ # :merge the text merge's result replaces it (Merger::Outcome)
19
+ # :decide not a function of kind and mark: the caller asks
20
+ # Merger::Upgrade#decision, and FileActions.decided
21
+ # turns its Outcome into the action
22
+ # :keep left as it is (already the bundle's, or kept)
23
+ # :skip left as it is, the bundle doesn't take it
24
+ # :move_to_trash moved to the trash (a removal)
25
+ # :refuse the command stops
26
+ # :restore the bundle's text put back (SystemBundle at its version)
27
+ # save_first: a copy of the old bytes goes to the trash before a write
28
+ # (when they differ from what is written)
29
+ # next_base: :on_disk (the file as the command leaves it), :incoming,
30
+ # :recorded (the base it has; none stays none), :none (no entry left)
31
+ # -- Merger::Outcome#base's vocabulary
32
+ # word: the report bucket the file is on (Installer#summary)
33
+ # record: the entry afterwards: :own (rebuilt, no mark), :keep_entry
34
+ # (kept with its base, no mark), :mark (marked behind), :hold_mark (as
35
+ # it was, mark and all), :clear_mark, :drop, :not_owned (never recorded)
36
+ Action = Data.define(:do, :save_first, :next_base, :word, :record) do
37
+ def initialize(do:, word:, next_base: :recorded, record: :own, save_first: false) = super
38
+
39
+ # The file stays behind the bundle, marked (a conflict to the report).
40
+ def behind? = record == :mark
41
+
42
+ # The bundle's text goes into the file: written (installed, forced or
43
+ # fast-forwarded) or merged into a local edit. The source file is the
44
+ # download, so the manifest's sha checks it (Installer's integrity
45
+ # check). :restore is SystemBundle's; it checks its own copy.
46
+ def takes_source? = %i[write_incoming merge].include?(self.do)
47
+ end
48
+
49
+ WRITE = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk)
50
+ UP_TO_DATE = Action.new(do: :keep, word: :up_to_date, next_base: :on_disk)
51
+ USER_SAME = Action.new(do: :keep, word: :up_to_date, next_base: :none, record: :not_owned)
52
+ USERS_OWN = Action.new(do: :skip, word: :kept_own, next_base: :none, record: :not_owned)
53
+ KEPT_OWN = Action.new(do: :skip, word: :kept_own, record: :keep_entry)
54
+ HELD = Action.new(do: :skip, word: :kept_own, record: :hold_mark)
55
+ FAST_FORWARD = Action.new(do: :write_incoming, word: :updated, next_base: :on_disk)
56
+ KEEP = Action.new(do: :keep, word: :kept)
57
+ # No base: no merge is possible, so the file stays behind, marked.
58
+ BEHIND = Action.new(do: :keep, word: :behind, record: :mark)
59
+ DECIDE = Action.new(do: :decide, word: nil)
60
+ REFUSE = Action.new(do: :refuse, word: nil, record: :hold_mark)
61
+ FORCED = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk, save_first: true)
62
+ # The bundle dropped it: a plain install keeps it and its entry (the
63
+ # upgrade it points at moves it), an upgrade moves it unless edited.
64
+ DROPPED_KEPT = Action.new(do: :keep, word: nil, record: :keep_entry)
65
+ DROPPED_GONE = Action.new(do: :skip, word: nil, next_base: :none, record: :drop)
66
+ REMOVE = Action.new(do: :move_to_trash, word: :removed, next_base: :none, record: :drop)
67
+ KEPT_PRUNED = Action.new(do: :keep, word: :kept_pruned, record: :keep_entry)
68
+ KEPT_SHARED = Action.new(do: :keep, word: :kept_shared, next_base: :none, record: :drop)
69
+
70
+ # Rows every installing command shares: files the bundle doesn't own.
71
+ NOT_OWNED = {
72
+ %i[new any] => WRITE, %i[user_same any] => USER_SAME, %i[user_file any] => USERS_OWN,
73
+ %i[missing any] => WRITE, %i[level any] => UP_TO_DATE, %i[unreadable any] => REFUSE
74
+ }.freeze
75
+
76
+ # A plain install (a re-install over the record, or a fresh one).
77
+ INSTALL = NOT_OWNED.merge(
78
+ # A file never edited takes the source's text, as an upgrade's
79
+ # fast-forward does (D4); an edited one stays (an upgrade merges it).
80
+ %i[pristine none] => FAST_FORWARD, %i[pristine_no_base none] => FAST_FORWARD,
81
+ %i[edited none] => KEPT_OWN, %i[diverged none] => KEPT_OWN, %i[edited_no_base none] => KEPT_OWN,
82
+ # A marked file it skips has missed the bundle's change as much as before.
83
+ %i[pristine any] => HELD, %i[edited any] => HELD, %i[diverged any] => HELD,
84
+ %i[pristine_no_base any] => HELD, %i[edited_no_base any] => HELD,
85
+ %i[dropped_pristine any] => DROPPED_KEPT, %i[dropped_edited any] => DROPPED_KEPT,
86
+ %i[dropped_missing any] => DROPPED_GONE
87
+ ).freeze
88
+
89
+ # install --force and upgrade --force: the bundle's text everywhere, an
90
+ # edit copied to the trash first. Its dropped rows are upgrade --force's
91
+ # (a plain install --force never prunes: its dropped files are INSTALL's).
92
+ FORCE = NOT_OWNED.merge(
93
+ # A file that already has the bundle's text is written all the same
94
+ # (nothing to save): it is installed, and checked, as any other.
95
+ %i[new any] => FORCED, %i[user_same any] => FORCED, %i[user_file any] => FORCED, %i[level any] => FORCED,
96
+ %i[pristine any] => FORCED, %i[edited any] => FORCED, %i[diverged any] => FORCED,
97
+ %i[pristine_no_base any] => FORCED, %i[edited_no_base any] => FORCED,
98
+ %i[dropped_pristine any] => REMOVE, %i[dropped_edited any] => REMOVE, %i[dropped_missing any] => DROPPED_GONE,
99
+ %i[dropped_pristine shared] => KEPT_SHARED, %i[dropped_edited shared] => KEPT_SHARED
100
+ ).freeze
101
+
102
+ # An upgrade's 3-way view (Merger::Upgrade#outcome).
103
+ UPGRADE = NOT_OWNED.merge(
104
+ %i[pristine none] => FAST_FORWARD,
105
+ # A marked file that equals its base takes the update; a copy is kept.
106
+ %i[pristine current] => FAST_FORWARD.with(save_first: true),
107
+ # An older chi's mark: fast-forward unless it takes a line out and
108
+ # ShippedHistory can't prove the base (the legacy guard).
109
+ %i[pristine legacy] => DECIDE,
110
+ %i[edited any] => KEEP,
111
+ # The text merge: merged, a conflict, or already merged.
112
+ %i[diverged any] => DECIDE,
113
+ # No base: no merge. One never edited (the checksum vouches for it)
114
+ # takes the update as a file equal to its base would (U4); a marked
115
+ # one, or an edit, stays behind.
116
+ %i[pristine_no_base none] => FAST_FORWARD,
117
+ %i[pristine_no_base any] => BEHIND, %i[edited_no_base any] => BEHIND,
118
+ %i[dropped_pristine any] => REMOVE, %i[dropped_edited any] => KEPT_PRUNED, %i[dropped_missing any] => DROPPED_GONE,
119
+ %i[dropped_pristine shared] => KEPT_SHARED, %i[dropped_edited shared] => KEPT_SHARED
120
+ ).freeze
121
+
122
+ # Remerge: the retry of a marked file at the same version. Unmarked
123
+ # files are never asked. Before the table, a mark whose source moved
124
+ # stays behind as it is (FileState#source_moved?).
125
+ REMERGE = {
126
+ %i[level any] => Action.new(do: :keep, word: :settled, next_base: :on_disk, record: :clear_mark),
127
+ %i[edited any] => Action.new(do: :keep, word: :settled, record: :clear_mark),
128
+ %i[diverged any] => DECIDE,
129
+ # It matches its old base: the next upgrade takes the update.
130
+ %i[pristine current] => BEHIND,
131
+ %i[pristine legacy] => DECIDE,
132
+ %i[pristine_no_base any] => BEHIND, %i[edited_no_base any] => BEHIND,
133
+ # Not shipped now: behind, nothing to merge.
134
+ %i[dropped_pristine any] => BEHIND, %i[dropped_edited any] => BEHIND,
135
+ %i[dropped_missing any] => Action.new(do: :skip, word: nil, record: :hold_mark),
136
+ # The next upgrade or a restore puts it back.
137
+ %i[missing any] => Action.new(do: :skip, word: nil, record: :hold_mark),
138
+ %i[unreadable any] => Action.new(do: :keep, word: :behind, record: :hold_mark)
139
+ }.freeze
140
+
141
+ # SystemBundle at its version: a memory not on disk is put back, one the
142
+ # record doesn't own too (research Q2, today's behaviour).
143
+ RESTORE = {
144
+ %i[missing any] => Action.new(do: :restore, word: :restored, next_base: :on_disk),
145
+ %i[new any] => Action.new(do: :restore, word: :restored, next_base: :on_disk)
146
+ }.freeze
147
+
148
+ # Uninstall, on the source-less kinds: an edit refuses (Uninstaller's
149
+ # --force moves it instead); a file another bundle lists stays.
150
+ UNINSTALL = {
151
+ %i[pristine any] => REMOVE, %i[pristine_no_base any] => REMOVE,
152
+ %i[edited any] => REFUSE, %i[edited_no_base any] => REFUSE, %i[unreadable any] => REFUSE,
153
+ %i[missing any] => DROPPED_GONE,
154
+ %i[pristine shared] => KEPT_SHARED, %i[pristine_no_base shared] => KEPT_SHARED,
155
+ %i[edited shared] => KEPT_SHARED, %i[edited_no_base shared] => KEPT_SHARED
156
+ }.freeze
157
+
158
+ TABLES = { install: INSTALL, force: FORCE, upgrade: UPGRADE, remerge: REMERGE, restore: RESTORE,
159
+ uninstall: UNINSTALL }.freeze
160
+ # The kinds whose row depends on another bundle listing the file.
161
+ SHARED_ASKED = %i[dropped_pristine dropped_edited pristine pristine_no_base edited edited_no_base].freeze
162
+
163
+ # The command's action for +state+: [kind, :shared] when another bundle
164
+ # lists a file that has such a row, else [kind, mark], then [kind, :any].
165
+ # @param command [Symbol] a TABLES key
166
+ # @return [Action, nil] nil: the command never meets the state
167
+ def self.for(command, state)
168
+ table = TABLES.fetch(command)
169
+ if SHARED_ASKED.include?(state.kind) && table.key?([state.kind, :shared]) && state.shared_with.any?
170
+ return table.fetch([state.kind, :shared])
171
+ end
172
+
173
+ table[[state.kind, state.mark]] || table[[state.kind, :any]]
174
+ end
175
+
176
+ # A :decide cell's answer: the action Merger::Upgrade#decision's Outcome
177
+ # stands for. Remerge never fast-forwards (the next upgrade does) and
178
+ # settles a file with nothing left to miss.
179
+ # @param outcome [Merger::Outcome]
180
+ # @param command [Symbol] :upgrade or :remerge
181
+ def self.decided(outcome, command = :upgrade)
182
+ case outcome.status
183
+ when :merged then Action.new(do: :merge, word: :merged, next_base: :incoming, save_first: true)
184
+ when :conflict then BEHIND
185
+ when :fast_forward then command == :remerge ? BEHIND : FAST_FORWARD.with(save_first: true)
186
+ when :keep, :noop
187
+ command == :remerge ? Action.new(do: :keep, word: :settled, next_base: outcome.base, record: :clear_mark) : KEEP.with(next_base: outcome.base)
188
+ else raise ArgumentError, "no action for outcome #{outcome.status.inspect}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -26,6 +26,19 @@ module Samagotchi
26
26
  hex = hex(path)
27
27
  hex && "sha256:#{hex}"
28
28
  end
29
+
30
+ # The integrity check's one warning: +path+'s bytes against the sha
31
+ # hex a manifest declares for it. nil when they match, or when there is
32
+ # nothing to check (no sha declared, no file).
33
+ # @param label [String] what the file is ("a.md", "hook guard.rb")
34
+ def self.mismatch(label, path, expected)
35
+ return nil if expected.nil? || expected.empty?
36
+
37
+ actual = hex(path)
38
+ return nil if actual.nil? || actual == expected
39
+
40
+ "Checksum mismatch for #{label}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
41
+ end
29
42
  end
30
43
  end
31
44
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_digest"
4
+ require_relative "installed_bundle"
5
+ require_relative "provenance"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # One bundle memory as a command meets it: which of a few states it is in,
10
+ # computed once from the file (D), its base (B), the bundle's text (I) and
11
+ # the record's entry (R). Derived, never stored. FileActions says what each
12
+ # acting command does in each state.
13
+ #
14
+ # kind, with a source (install, upgrade, remerge, restore, plan):
15
+ # :new not the bundle's, not on disk
16
+ # :user_same not the bundle's, on disk with I's bytes
17
+ # :user_file not the bundle's, other bytes (the user's own file)
18
+ # :missing the bundle's, not on disk
19
+ # :level the bundle's, D == I
20
+ # :pristine never edited (#edited?: D == R's checksum, or with
21
+ # none D == B): the bundle's text as installed
22
+ # :edited edited, D != B, I == B: the user changed it, the
23
+ # bundle didn't
24
+ # :diverged edited, D != B, I != B: both changed it
25
+ # :pristine_no_base no B, never edited (D == R's checksum)
26
+ # :edited_no_base no B, edited (or no checksum to tell); or a B equal
27
+ # to D that the checksum calls edited: no base to
28
+ # merge from
29
+ # :dropped_pristine / :dropped_edited / :dropped_missing the bundle no
30
+ # longer ships it (no I): unedited, edited, gone
31
+ # :unreadable on disk, but it can't be read
32
+ # Edited or not is always the one rule (#edited?, the checksum first);
33
+ # the base then says what the user changed.
34
+ #
35
+ # kind, with no source (status, uninstall, diff): I is unknown, so only
36
+ # :pristine :edited :pristine_no_base :edited_no_base :missing :unreadable,
37
+ # from #edited? alone.
38
+ #
39
+ # mark: :none, :current (a mark this chi wrote) or :legacy (an older
40
+ # chi's, InstalledBundle.legacy_mark?).
41
+ FileState = Data.define(:key, :kind, :mark, :digests, :entry, :paths, :claimants) do
42
+ # Provenance.unchanged?'s answer negated: the one edited rule (checksum
43
+ # first, then the base). A file it can't read counts as edited.
44
+ def edited?
45
+ return false if entry.nil?
46
+
47
+ !Provenance.unchanged?(paths[:current], entry, paths[:base])
48
+ rescue SystemCallError
49
+ true
50
+ end
51
+
52
+ def marked? = mark != :none
53
+
54
+ # A marked file whose source moved since the mark: the mark speaks of
55
+ # other text than the bundle ships now (Remerge's first question). A mark
56
+ # that names no text (an older chi's) never moved.
57
+ def source_moved?
58
+ marked? && !digests.marked_incoming.nil? && !digests.incoming.nil? && digests.marked_incoming != digests.incoming
59
+ end
60
+
61
+ # The other installed bundles that list the file too. Asked lazily: only
62
+ # a dropped file and an uninstall need it (Provenance.claimants reads
63
+ # every record).
64
+ def shared_with = claimants.call
65
+ end
66
+
67
+ # The digests of the file's texts, each read once: disk (:unreadable when
68
+ # it can't be read), base, incoming (nil with no source), the record's
69
+ # checksum, and the text a mark speaks of (nil without a mark or for an
70
+ # older chi's mark).
71
+ FileState::Digests = Data.define(:disk, :base, :incoming, :recorded, :marked_incoming)
72
+
73
+ class << FileState
74
+ # @param record [InstalledBundle, nil] the bundle's record
75
+ # @param provenance [Provenance] the record's (for the base path)
76
+ # @param source_dir [String, nil] the bundle's incoming dir; nil: the
77
+ # source-less kinds
78
+ # @param claimants [#call, nil] key => other bundles listing it
79
+ # (Provenance.claimants), called at most once
80
+ # @param shipped [Boolean] false: the bundle no longer ships the key
81
+ # (a dropped kind), whatever its source dir holds by that name
82
+ # @return [FileState]
83
+ def of(key, record:, provenance:, target_dir:, source_dir:, claimants: nil, shipped: true)
84
+ entry = record&.files&.fetch(key, nil)
85
+ paths = { base: provenance.base_path(key), current: File.join(target_dir, key),
86
+ incoming: source_dir && File.join(source_dir, key) }
87
+ mark = mark_of(entry)
88
+ digests = FileState::Digests.new(
89
+ disk: digest(paths[:current]), base: entry ? digest(paths[:base]) : nil,
90
+ incoming: source_dir && shipped ? digest(paths[:incoming]) : nil, recorded: entry && Provenance.recorded_sha(entry[:checksum]),
91
+ marked_incoming: mark == :none ? nil : entry[:incoming_sha]
92
+ )
93
+ shared = nil
94
+ lookup = -> { shared ||= claimants ? claimants.call(key) : [] }
95
+ state = FileState.new(key: key, kind: nil, mark: mark, digests: digests, entry: entry, paths: paths, claimants: lookup)
96
+ state.with(kind: source_dir ? kind_with_source(state) : kind_without_source(state))
97
+ end
98
+
99
+ private
100
+
101
+ def mark_of(entry)
102
+ return :none unless entry && entry[:conflict]
103
+
104
+ InstalledBundle.legacy_mark?(entry) ? :legacy : :current
105
+ end
106
+
107
+ def digest(path)
108
+ FileDigest.hex(path)
109
+ rescue SystemCallError
110
+ :unreadable
111
+ end
112
+
113
+ def kind_with_source(state)
114
+ d = state.digests
115
+ return not_owned(d) unless state.entry
116
+ return :unreadable if d.disk == :unreadable
117
+ return dropped(state) if d.incoming.nil?
118
+ return :missing if d.disk.nil?
119
+ return :level if d.disk == d.incoming
120
+ return state.edited? ? :edited_no_base : :pristine_no_base if d.base.nil?
121
+ return :pristine unless state.edited?
122
+ # Edited by the one rule, yet equal to its base: the base can't say
123
+ # what the user changed (it isn't the text the record vouches for),
124
+ # so there is none to merge from.
125
+ return :edited_no_base if d.disk == d.base
126
+
127
+ d.incoming == d.base ? :edited : :diverged
128
+ end
129
+
130
+ def kind_without_source(state)
131
+ d = state.digests
132
+ return :unreadable if d.disk == :unreadable
133
+ return :missing if d.disk.nil?
134
+ return state.edited? ? :edited_no_base : :pristine_no_base if d.base.nil?
135
+
136
+ state.edited? ? :edited : :pristine
137
+ end
138
+
139
+ def not_owned(digests)
140
+ return :new if digests.disk.nil?
141
+
142
+ digests.disk == digests.incoming ? :user_same : :user_file
143
+ end
144
+
145
+ def dropped(state)
146
+ return :dropped_missing if state.digests.disk.nil?
147
+
148
+ state.edited? ? :dropped_edited : :dropped_pristine
149
+ end
150
+ end
151
+ end
152
+ end
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # recorded is gone (:missing). false with no dir.
120
120
  def manifest_unkept? = !dir.nil? && (manifest_sha.to_s.empty? || !File.file?(kept_manifest_path))
121
121
 
122
- # What its manifest says (requires_chi, hook metadata, needs, providers,
122
+ # What its manifest says (requires_chi, needs, providers,
123
123
  # renderers, settings…): the one way to read manifest data of an
124
124
  # installed bundle.
125
125
  # @return [InstalledManifest]
@@ -3,27 +3,28 @@
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
5
  require "yaml"
6
- require_relative "bundle_hook"
7
6
  require_relative "manifest"
8
7
  require_relative "provenance"
9
8
 
10
9
  module Samagotchi
11
10
  module MemoryBundle
12
- # An installed bundle laid out as a source folder again: the plugin, hooks,
11
+ # An installed bundle laid out as a source folder again: the plugin,
13
12
  # rules and scripts as they are installed (a dev install's links
14
13
  # followed), the memories as their bases (the bundle's bytes, not the
15
14
  # user's edits). A bundle's source may be gone or be a URL; this is what
16
15
  # `chi update` can check of what really runs.
17
16
  #
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
17
+ # manifest.yml: its files:, plugin: and scripts: are the record's and
18
+ # the copy's (what is installed: a memory the install left out, or one
19
+ # with no base, isn't listed, so the check finds no missing-file for it).
20
+ # It has no hooks: and no hooks/, whatever the record or the kept
21
+ # manifest says: class-file hooks don't load (InstalledCheck names a
22
+ # record's once). Every other key is the kept
22
23
  # manifest.yml's when it is read (InstalledManifest origin :kept), so the
23
24
  # check sees its unknown keys too; else the record's mirror's.
24
25
  #
25
26
  # The shas are the files' own, so an edited plugin is checked as written.
26
- # A plugin, hook or script the record lists that isn't there stays
27
+ # A plugin or script the record lists that isn't there stays
27
28
  # listed, for the check's missing-file.
28
29
  module InstalledCopy
29
30
  module_function
@@ -45,7 +46,7 @@ module Samagotchi
45
46
  dir: dir, name: bundle.name, version: bundle.version.to_s.empty? ? "0" : bundle.version, trust_level: manifest.trust_level,
46
47
  files: memories(bundle, provenance, dir),
47
48
  file_descriptions: manifest.file_descriptions,
48
- hooks: hooks(bundle, provenance, dir), plugin: plugin(bundle, provenance, dir),
49
+ plugin: plugin(bundle, provenance, dir),
49
50
  scripts: copies(bundle.scripts, provenance.scripts_dir, File.join(dir, "scripts")),
50
51
  requires_chi: manifest.requires_chi, needs: manifest.needs,
51
52
  context_providers: manifest.context_providers, block_renderers: manifest.block_renderers,
@@ -57,7 +58,8 @@ module Samagotchi
57
58
  end
58
59
  private_class_method :write
59
60
 
60
- # What is installed, which the copy's manifest.yml takes from the record.
61
+ # What is installed, which the copy's manifest.yml takes from the
62
+ # record (hooks: none, so the kept manifest's isn't taken either).
61
63
  INSTALLED_KEYS = %w[name version files hooks plugin scripts].freeze
62
64
 
63
65
  # The written manifest's INSTALLED_KEYS and +failed+ keys (the value
@@ -80,20 +82,12 @@ module Samagotchi
80
82
  end
81
83
  private_class_method :memories
82
84
 
83
- # The recorded hooks with the metadata they load with (InstalledManifest#hooks).
84
- def hooks(bundle, provenance, dir)
85
- bundle.manifest.hooks.to_h do |file, hook|
86
- sha = copy(File.join(provenance.hooks_dir, file), File.join(dir, "hooks", file), hook.sha256)
87
- [file, hook.with(sha256: sha)]
88
- end
89
- end
90
- private_class_method :hooks
91
-
92
85
  def plugin(bundle, provenance, dir)
93
86
  return nil unless bundle.plugin
94
87
 
95
88
  file = bundle.plugin.file
96
- { file: file, sha256: copy(provenance.plugin_path(bundle), File.join(dir, file), bundle.plugin.sha256) }
89
+ { file: file, sha256: copy(provenance.plugin_path(bundle), File.join(dir, file), bundle.plugin.sha256),
90
+ required: bundle.manifest.plugin_required }
97
91
  end
98
92
  private_class_method :plugin
99
93
 
@@ -39,9 +39,6 @@ module Samagotchi
39
39
  #
40
40
  # requires_chi, trust_level the strings as recorded, nil when none
41
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
42
  # needs Manifest.parse_needs'
46
43
  # script_names the scripts/ files it lists
47
44
  # context_providers Array<ContextProviders::Provider>
@@ -50,16 +47,21 @@ module Samagotchi
50
47
  # as Manifest reads them (settings nil when
51
48
  # the manifest said nothing of them)
52
49
  # file_descriptions file key => the memory's description
50
+ # plugin_required plugin: required: true (a guardrail plugin:
51
+ # one that fails to load denies every tool
52
+ # call): the record's (PluginRef#required,
53
+ # recorded at install) or the kept manifest's,
54
+ # so a lost or edited kept file keeps it
53
55
  # includes a profile's recorded members (the record's
54
56
  # identity, never the kept file's), nil for a plain bundle
55
57
  # unknown_keys the kept manifest's keys this chi doesn't know
56
58
  # floor_keys the kept manifest's Manifest#floor_keys (key =>
57
59
  # the first chi that read it), {} from the record
58
60
  # origin :kept or :record
59
- class InstalledManifest < Data.define(:name, :version, :requires_chi, :trust_level, :description, :hooks, :needs,
61
+ class InstalledManifest < Data.define(:name, :version, :requires_chi, :trust_level, :description, :needs,
60
62
  :script_names, :context_providers, :block_renderers, :session_settings,
61
63
  :session_setting_types, :settings, :setting_types, :file_descriptions,
62
- :includes, :unknown_keys, :floor_keys, :problems, :origin)
64
+ :plugin_required, :includes, :unknown_keys, :floor_keys, :problems, :origin)
63
65
  # The key problems names the kept file's standing under.
64
66
  KEPT_FILE = "manifest.yml"
65
67
  # How many manifests the cache holds before it starts again.
@@ -125,7 +127,6 @@ module Samagotchi
125
127
  new(name: bundle.name, version: bundle.version,
126
128
  requires_chi: pick.call("requires_chi", kept.requires_chi, mirrored.requires_chi),
127
129
  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
130
  needs: pick.call("needs", kept.needs, mirrored.needs),
130
131
  script_names: pick.call("scripts", kept.scripts.keys, mirrored.script_names),
131
132
  context_providers: pick.call("context_providers", kept.context_providers, mirrored.context_providers),
@@ -133,6 +134,7 @@ module Samagotchi
133
134
  session_settings: session.session_settings, session_setting_types: session.session_setting_types,
134
135
  settings: settings.settings, setting_types: settings.setting_types,
135
136
  file_descriptions: pick.call("files", kept.file_descriptions, mirrored.file_descriptions),
137
+ plugin_required: kept.plugin&.dig(:required) == true || mirrored.plugin_required,
136
138
  includes: bundle.includes,
137
139
  unknown_keys: kept.unknown_keys.map(&:to_s), floor_keys: floors(kept), problems: problems.freeze, origin: :kept)
138
140
  end
@@ -144,21 +146,11 @@ module Samagotchi
144
146
  {}
145
147
  end
146
148
 
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
149
  def self.from_record(bundle)
157
150
  raw = recorded(bundle)
158
151
  problems = {}
159
152
  new(name: bundle.name, version: bundle.version,
160
153
  requires_chi: raw["requires_chi"]&.to_s, trust_level: raw["trust_level"]&.to_s, description: nil,
161
- hooks: bundle.hooks,
162
154
  needs: parsed(problems, "needs", [], Manifest::ValidationError) { Manifest.parse_needs(raw["needs"]) },
163
155
  script_names: bundle.scripts.keys,
164
156
  context_providers: parsed(problems, "context_providers", []) { ContextProviders.parse_list(raw["context_providers"]) },
@@ -168,6 +160,7 @@ module Samagotchi
168
160
  settings: list(raw["settings"]),
169
161
  setting_types: types(raw["setting_types"]),
170
162
  file_descriptions: file_descriptions(bundle.files),
163
+ plugin_required: bundle.plugin&.required == true,
171
164
  includes: bundle.includes, unknown_keys: [], floor_keys: {}, problems: problems.freeze, origin: :record)
172
165
  end
173
166
 
@@ -197,7 +190,7 @@ module Samagotchi
197
190
  acc[key] = text unless text.empty?
198
191
  end
199
192
  end
200
- private_class_method :deep_freeze, :kept_stat, :build, :standing, :from_kept, :floors, :kept_hooks, :from_record, :recorded, :parsed,
193
+ private_class_method :deep_freeze, :kept_stat, :build, :standing, :from_kept, :floors, :from_record, :recorded, :parsed,
201
194
  :list, :types, :file_descriptions
202
195
 
203
196
  # Why the kept manifest.yml wasn't read: :missing, :stale or :edited (the class