samagotchi 0.160.0 → 0.161.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. metadata +5 -1
@@ -50,7 +50,8 @@ module Samagotchi
50
50
  # @param chi_version [String] the chi this install is for (the Installer's;
51
51
  # a caller that checks its own bundles against another version passes it)
52
52
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
53
- force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION)
53
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION,
54
+ refuse_mismatch: false)
54
55
  @name = name
55
56
  @bundle_dir = source_dir
56
57
  @manifest = manifest
@@ -66,11 +67,42 @@ module Samagotchi
66
67
  @dry_run = dry_run
67
68
  @dev = dev
68
69
  @chi_version = chi_version
70
+ @refuse_mismatch = refuse_mismatch
71
+ end
72
+
73
+ # Raises for the first plugin or script the manifest names that the
74
+ # bundle doesn't have (or ships as a link), writing nothing: the
75
+ # Installer runs it before its first write.
76
+ # @raise [Installer::InstallError]
77
+ def check_named_files
78
+ plugin_source
79
+ scripts_sources
80
+ end
81
+
82
+ # The same early pass on the shas: the plugin and each script against the
83
+ # bundle's own bytes, before anything is written. Only when the caller
84
+ # refuses a mismatch (`chi bundle install/upgrade`; chi update, a profile
85
+ # and a dev install keep their warning), and not with --force, which
86
+ # installs a mismatch anyway.
87
+ # @raise [Installer::InstallError] one line per mismatched file
88
+ def check_named_checksums
89
+ return unless @refuse_mismatch && @strict && !@dev && !@force && @manifest
90
+
91
+ lines = []
92
+ if @manifest.plugin
93
+ lines << Installer.mismatch_refusal("plugin #{@manifest.plugin[:file]}", plugin_source,
94
+ @manifest.checksum_for_plugin, upgrade: @upgrade)
95
+ end
96
+ scripts_sources.each do |name, src|
97
+ lines << Installer.mismatch_refusal("script #{name}", src, declared_sha(:script, name), upgrade: @upgrade)
98
+ end
99
+ lines = lines.compact
100
+ raise Installer::InstallError, lines.join("\n") unless lines.empty?
69
101
  end
70
102
 
71
103
  # @return [Installed]
72
- # @raise [Installer::InstallError] the manifest names a plugin or
73
- # script the bundle doesn't have
104
+ # @raise [Installer::InstallError] the same, when #check_named_files
105
+ # wasn't run first
74
106
  def run
75
107
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
76
108
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
@@ -137,30 +169,45 @@ module Samagotchi
137
169
  plugin = @manifest&.plugin
138
170
  return {} unless plugin
139
171
 
140
- src = File.join(@bundle_dir, plugin[:file])
141
- raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
142
- raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
143
-
172
+ src = plugin_source
144
173
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
145
174
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
146
175
  end
147
176
  { plugin[:file] => src }
148
177
  end
149
178
 
150
- # The manifest's scripts (a dev install warns of one it can't run).
179
+ # The manifest's plugin file in the source, checked; nil without one.
180
+ def plugin_source
181
+ plugin = @manifest&.plugin
182
+ return unless plugin
183
+
184
+ src = File.join(@bundle_dir, plugin[:file])
185
+ raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
186
+ raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
187
+
188
+ src
189
+ end
190
+
191
+ # The manifest's scripts in the source, checked.
151
192
  # @return [Hash{String => String}] file name => the source file
152
- def scripts_incoming
153
- scripts = @manifest&.scripts || {}
154
- scripts.keys.to_h do |file|
193
+ def scripts_sources
194
+ (@manifest&.scripts || {}).keys.to_h do |file|
155
195
  src = File.join(@bundle_dir, "scripts", file)
156
196
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
157
197
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
158
198
 
159
- @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
160
199
  [file, src]
161
200
  end
162
201
  end
163
202
 
203
+ # The manifest's scripts (a dev install warns of one it can't run).
204
+ # @return [Hash{String => String}] file name => the source file
205
+ def scripts_incoming
206
+ scripts_sources.each do |file, src|
207
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
208
+ end
209
+ end
210
+
164
211
  # One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
165
212
  # replaced whole by +incoming+: each file's AssetState and its action
166
213
  # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
@@ -11,6 +11,10 @@ module Samagotchi
11
11
  # the record's entry (R). Derived, never stored. FileActions says what each
12
12
  # acting command does in each state.
13
13
  #
14
+ # A file whose disk bytes are the text an interrupted run journaled
15
+ # (Provenance#write_pending) reads as the bundle's, at that run's
16
+ # version: owned (an entry made up if the record has none), its own base.
17
+ #
14
18
  # kind, with a source (install, upgrade, remerge, restore, plan):
15
19
  # :new not the bundle's, not on disk
16
20
  # :user_same not the bundle's, on disk with I's bytes
@@ -82,6 +86,7 @@ module Samagotchi
82
86
  # @return [FileState]
83
87
  def of(key, record:, provenance:, target_dir:, source_dir:, claimants: nil, shipped: true)
84
88
  entry = record&.files&.fetch(key, nil)
89
+ pending = provenance.pending_shas(key, target_dir: target_dir)
85
90
  paths = { base: provenance.base_path(key), current: File.join(target_dir, key),
86
91
  incoming: source_dir && File.join(source_dir, key) }
87
92
  mark = mark_of(entry)
@@ -90,6 +95,12 @@ module Samagotchi
90
95
  incoming: source_dir && shipped ? digest(paths[:incoming]) : nil, recorded: entry && Provenance.recorded_sha(entry[:checksum]),
91
96
  marked_incoming: mark == :none ? nil : entry[:incoming_sha]
92
97
  )
98
+ if !pending.empty? && pending.include?(digests.disk)
99
+ # An interrupted run put this text here (Provenance#write_pending): it is the bundle's at
100
+ # that version, owned and its own base, not the user's file or an edit of the recorded one.
101
+ entry = (entry || {}).merge(checksum: digests.disk)
102
+ digests = digests.with(base: digests.disk, recorded: digests.disk)
103
+ end
93
104
  shared = nil
94
105
  lookup = -> { shared ||= claimants ? claimants.call(key) : [] }
95
106
  state = FileState.new(key: key, kind: nil, mark: mark, digests: digests, entry: entry, paths: paths, claimants: lookup)
@@ -14,8 +14,11 @@ module Samagotchi
14
14
  # @param source [String, false, nil] the bundle the memory came from
15
15
  # ("· from <bundle>"): a name sets it, false drops it, nil keeps the
16
16
  # line's (memory_write and write/edit pass nil)
17
- def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
18
- index_path = index_path_for(scope)
17
+ # @param target_dir [String, nil] the folder whose index.md to edit, when
18
+ # it is not the one +scope+ resolves from the current folder (an
19
+ # uninstall of a bundle journaled into another project)
20
+ def self.update_index(scope, entry_name, byte_count, description = nil, source: nil, target_dir: nil)
21
+ index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
19
22
  return true unless index_path
20
23
 
21
24
  locked_write(File.dirname(index_path)) do |content|
@@ -39,8 +42,8 @@ module Samagotchi
39
42
  /^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
40
43
  end
41
44
 
42
- def self.remove_index(scope, entry_name)
43
- index_path = index_path_for(scope)
45
+ def self.remove_index(scope, entry_name, target_dir: nil)
46
+ index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
44
47
  return true unless index_path && File.exist?(index_path)
45
48
 
46
49
  locked_write(File.dirname(index_path)) do |content|
@@ -61,7 +61,8 @@ module Samagotchi
61
61
  # chi_version: the chi this install is for (Profile and ShippedUpdate
62
62
  # check their bundles against a version they are given); default this chi.
63
63
  def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
64
- shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false, chi_version: Samagotchi::VERSION)
64
+ shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false, chi_version: Samagotchi::VERSION,
65
+ refuse_mismatch: false)
65
66
  @source = source
66
67
  @shipped_dir = shipped_dir
67
68
  @prepared = prepared
@@ -73,6 +74,10 @@ module Samagotchi
73
74
  @dry_run = dry_run
74
75
  @dev = dev
75
76
  @chi_version = chi_version
77
+ # `chi bundle install/upgrade` refuses a file whose bytes disagree with
78
+ # its manifest sha before writing anything; the callers that only warn
79
+ # (ShippedUpdate, SystemBundle, Profile) leave it unset.
80
+ @refuse_mismatch = refuse_mismatch
76
81
  @results = {}
77
82
  @warnings = []
78
83
  @placeholder_warnings = []
@@ -119,10 +124,11 @@ module Samagotchi
119
124
  def run
120
125
  load_source
121
126
  refuse_too_new
127
+ @provenance = Provenance.new(name: @name)
128
+ refuse_newer_record_format
122
129
  warn_unknown_keys(@manifest) if @manifest
123
130
  @target_scope, @target_dir = resolve_target
124
131
  @file_descriptions = @manifest&.file_descriptions || {}
125
- @provenance = Provenance.new(name: @name)
126
132
  @existing = @provenance.record
127
133
  check_source
128
134
  # A plain install over an installed bundle: its summary says so.
@@ -212,6 +218,18 @@ module Samagotchi
212
218
  "(to leave dev mode: chi bundle uninstall #{name}, then chi bundle install without --dev)"
213
219
  end
214
220
 
221
+ # The line a refused install names one file with: the checksum warning and
222
+ # the way past it. Only `chi bundle install/upgrade` refuses a mismatch
223
+ # (--force skips the early check and keeps the post-write warning); the
224
+ # callers that only warn (chi update, SystemBundle, a profile) never reach
225
+ # this.
226
+ # @param label [String] "a.md", "plugin plugin.rb", "script s.sh"
227
+ # @param upgrade [Boolean] which command the hint names
228
+ def self.mismatch_refusal(label, path, expected, upgrade:)
229
+ warning = FileDigest.mismatch(label, path, expected)
230
+ warning && "#{warning}; chi bundle #{upgrade ? "upgrade" : "install"} --force installs it anyway"
231
+ end
232
+
215
233
  private
216
234
 
217
235
  # The files whose result has one of +words+, in the order they ran.
@@ -233,28 +251,71 @@ module Samagotchi
233
251
  line
234
252
  end
235
253
 
236
- # The steps that replace files: memories, the bundle's own folder, then
237
- # the files it dropped. The record isn't written yet, so a failed run is
254
+ # The steps that replace files, after the check of the files the manifest
255
+ # names: memories, the bundle's own folder, then the files it dropped. The record isn't written yet, so a failed run is
238
256
  # retried as the same upgrade.
239
257
  # @return [AssetInstaller::Installed]
240
258
  # @raise [InstallError] a file system error (a file it can't read, a
241
259
  # .trash it can't write)
242
260
  def replace_files
243
- install_memories
244
-
245
- installed = AssetInstaller.new(
261
+ assets = AssetInstaller.new(
246
262
  name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
247
263
  results: @results, warnings: @warnings, conflicts: @conflicts, trash: @trash,
248
- force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev, chi_version: @chi_version
249
- ).run
264
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev, chi_version: @chi_version,
265
+ refuse_mismatch: @refuse_mismatch
266
+ )
267
+ # Before the first write: a plugin or script the bundle lacks refuses the run with disk and record as they were.
268
+ assets.check_named_files
269
+ # And, for the commands that refuse it (--force aside), a file whose
270
+ # bytes disagree with the sha its manifest declares.
271
+ refuse_memory_mismatches
272
+ assets.check_named_checksums
273
+ write_pending
274
+ install_memories
275
+ installed = assets.run
250
276
 
251
277
  # Files the previous version shipped and this one doesn't.
252
- prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
278
+ previous_files = @provenance.files_with_pending(@existing, target_dir: @target_dir)
279
+ prune_dropped_files(previous_files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
253
280
  installed
254
281
  rescue SystemCallError => e
255
282
  raise InstallError, Trash.failure_message(e)
256
283
  end
257
284
 
285
+ # The intent journal (Provenance#write_pending): the text of each memory
286
+ # this run will change, noted before the first write, so a run that
287
+ # stops before its record leaves files a retry still knows as the
288
+ # bundle's.
289
+ def write_pending
290
+ return if @dry_run || @manifest.nil?
291
+
292
+ incoming = memory_files.to_h { |key| [key, FileDigest.hex(File.join(@bundle_dir, key))] }
293
+ incoming.reject! do |key, sha|
294
+ sha.nil? || sha == FileDigest.hex(File.join(@target_dir, key))
295
+ rescue SystemCallError
296
+ true
297
+ end
298
+ @provenance.write_pending(version: @manifest.version, scope: @target_scope, target_dir: @target_dir, files: incoming) unless incoming.empty?
299
+ rescue SystemCallError => e
300
+ raise InstallError, Trash.failure_message(e)
301
+ end
302
+
303
+ # Before the first write, the bundle's own bytes against the sha its
304
+ # manifest declares for each memory: `chi bundle install/upgrade` refuses
305
+ # a mismatch (--force skips the check, and #verify_memory_checksums keeps
306
+ # the post-write warning for the callers that only warn). A dev install
307
+ # follows its own tree; a non-strict run has no shas to trust.
308
+ # @raise [InstallError] one line per mismatched file
309
+ def refuse_memory_mismatches
310
+ return unless @refuse_mismatch && @strict && !@dev && !@force && @manifest
311
+
312
+ lines = memory_files.filter_map do |file_key|
313
+ self.class.mismatch_refusal(file_key, File.join(@bundle_dir, file_key),
314
+ @manifest.checksum_for(file_key), upgrade: @upgrade)
315
+ end
316
+ raise InstallError, lines.join("\n") unless lines.empty?
317
+ end
318
+
258
319
  # Who may install under this name (a manifest can take any name: a
259
320
  # repo calling itself guardrails). Sources compare by SourceIdentity
260
321
  # (a shipped dir by name, in any gem or checkout; a git URL without
@@ -384,6 +445,12 @@ module Samagotchi
384
445
  raise InstallError, "#{@name} can't be installed: #{failure} (chi update, or --force to install it anyway)"
385
446
  end
386
447
 
448
+ def refuse_newer_record_format
449
+ @provenance.ensure_supported_record_format!
450
+ rescue StateFormat::InvalidDocument => e
451
+ raise InstallError, e.message
452
+ end
453
+
387
454
  # The scope the files go to (the CLI's wins over the manifest's) and
388
455
  # its dir, created.
389
456
  # @return [Array(String, String)] scope, dir
@@ -785,8 +852,8 @@ module Samagotchi
785
852
 
786
853
  # Not fatal, like update_target_index: a warning says so.
787
854
  def remove_target_index(scope, file_key)
788
- IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
789
- IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
855
+ IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"), target_dir: @target_dir)
856
+ IndexUpdater.remove_index(scope, file_key, target_dir: @target_dir) # legacy "name.md" line
790
857
  rescue StandardError => e
791
858
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not removed (#{e.message})"
792
859
  end
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require "tmpdir"
8
8
  require_relative "../atomic_file"
9
9
  require_relative "../memory_paths"
10
+ require_relative "../state_format"
10
11
  require_relative "../version"
11
12
  require_relative "file_digest"
12
13
  require_relative "installed_bundle"
@@ -32,6 +33,38 @@ module Samagotchi
32
33
  MARK_FIELDS = %w[conflict incoming_sha legacy_base why missed missed_places].freeze
33
34
  # What a merge no report has shown adds to a file's entry (#note_merged).
34
35
  MERGE_NOTE_FIELDS = %w[merged_version merged_at merged_trash].freeze
36
+ RECORD_FORMAT_VERSION = 1
37
+ RECORD_FORMAT = StateFormat.new(
38
+ version: RECORD_FORMAT_VERSION,
39
+ members: [
40
+ StateFormat.required(:name),
41
+ StateFormat.required(:version),
42
+ StateFormat.required(:scope),
43
+ StateFormat.required(:source),
44
+ StateFormat.required(:installed_at),
45
+ StateFormat.required(:files),
46
+ StateFormat.required(:hooks),
47
+ StateFormat.added(:record_format, in_version: 1, default: 1),
48
+ StateFormat.added(:dev, in_version: 1, default: false),
49
+ StateFormat.added(:guardrails, in_version: 1, default: {}),
50
+ StateFormat.added(:source_commit, in_version: 1, default: nil),
51
+ StateFormat.added(:plugin, in_version: 1, default: nil),
52
+ StateFormat.added(:includes, in_version: 1, default: nil),
53
+ StateFormat.added(:scripts, in_version: 1, default: {}),
54
+ StateFormat.added(:manifest_sha, in_version: 1, default: nil),
55
+ StateFormat.added(:installed_by, in_version: 1, default: nil),
56
+ StateFormat.added(:requires_chi, in_version: 1, default: nil),
57
+ StateFormat.added(:trust_level, in_version: 1, default: nil),
58
+ StateFormat.added(:needs, in_version: 1, default: nil),
59
+ StateFormat.added(:context_providers, in_version: 1, default: nil),
60
+ StateFormat.added(:block_renderers, in_version: 1, default: nil),
61
+ StateFormat.added(:session_settings, in_version: 1, default: nil),
62
+ StateFormat.added(:session_setting_types, in_version: 1, default: nil),
63
+ StateFormat.added(:settings, in_version: 1, default: nil),
64
+ StateFormat.added(:setting_types, in_version: 1, default: nil),
65
+ StateFormat.extra(:extra, in_version: 1)
66
+ ]
67
+ )
35
68
 
36
69
  def initialize(name:)
37
70
  @name = name
@@ -65,6 +98,55 @@ module Samagotchi
65
98
  File.join(@bundle_dir, "scripts")
66
99
  end
67
100
 
101
+ # The intent journal of a run that has not written its record yet
102
+ # (#write_pending): its scope and target dir, plus the files and sha256s
103
+ # of the texts a run meant to put there. #write removes it.
104
+ def pending_path
105
+ File.join(@bundle_dir, "pending.json")
106
+ end
107
+
108
+ # Notes, before an install's first write, the texts it is about to put
109
+ # in the memories dir, atomically. A journal an earlier interrupted run
110
+ # left keeps its shas (that run's files are still on disk).
111
+ # @param files [Hash{String => String}] memory => the incoming sha256
112
+ def write_pending(version:, scope:, target_dir:, files:)
113
+ shas = pending_files(target_dir: target_dir)
114
+ files.each { |key, sha| shas[key] = (shas.fetch(key, []) + [sha]).uniq }
115
+ FileUtils.mkdir_p(@bundle_dir)
116
+ AtomicFile.write(pending_path, JSON.pretty_generate(
117
+ "version" => version.to_s, "scope" => scope.to_s, "target_dir" => File.expand_path(target_dir), "files" => shas
118
+ ))
119
+ end
120
+
121
+ # The shas the journal names for +file_key+ (none without a journal, or
122
+ # with one that doesn't read or belongs to another target).
123
+ def pending_shas(file_key, target_dir:)
124
+ pending_files(target_dir: target_dir).fetch(file_key.to_s, [])
125
+ end
126
+
127
+ def pending_scope
128
+ pending_metadata&.fetch("scope")
129
+ end
130
+
131
+ def pending_target_dir
132
+ pending_metadata&.fetch("target_dir")
133
+ end
134
+
135
+ # The record's files plus interrupted files whose current bytes match a
136
+ # journaled sha. Files whose bytes no longer match are not claimed.
137
+ def files_with_pending(record, target_dir:)
138
+ files = record&.files&.dup || {}
139
+ pending_files(target_dir: target_dir).each do |key, shas|
140
+ next if files.key?(key)
141
+
142
+ disk_sha = FileDigest.hex(File.join(target_dir, key))
143
+ files[key] = {} if shas.include?(disk_sha)
144
+ rescue SystemCallError
145
+ next
146
+ end
147
+ files
148
+ end
149
+
68
150
  # The bundle's manifest.yml as installed (InstalledBundle#kept_manifest_path).
69
151
  def kept_manifest_path
70
152
  File.join(@bundle_dir, "manifest.yml")
@@ -233,6 +315,7 @@ module Samagotchi
233
315
  guardrails_files: {}, plugin_file: nil, plugin_required: false, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
234
316
  scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
235
317
  dev: false, manifest_from: nil)
318
+ record_extra = format_record(read)&.extra || {}
236
319
  FileUtils.mkdir_p(@bundle_dir)
237
320
  bases_dir = File.join(@bundle_dir, "bases")
238
321
  FileUtils.mkdir_p(bases_dir)
@@ -280,6 +363,7 @@ module Samagotchi
280
363
 
281
364
  manifest_data = {
282
365
  "name" => @name,
366
+ "record_format" => RECORD_FORMAT_VERSION,
283
367
  "version" => version.to_s,
284
368
  "scope" => scope.to_s,
285
369
  "source" => source_path.to_s,
@@ -314,8 +398,10 @@ module Samagotchi
314
398
  end
315
399
  end
316
400
  manifest_data.merge!(kept_manifest_fields(existing, manifest_from, dev))
401
+ manifest_data = record_extra.merge(manifest_data)
317
402
 
318
403
  write_manifest(manifest_data)
404
+ FileUtils.rm_f(pending_path)
319
405
  marked.each_key { |key| save_incoming(key, incoming_dir) }
320
406
  prune_incoming(marked.keys)
321
407
  end
@@ -330,7 +416,7 @@ module Samagotchi
330
416
  def resolve_conflicts(conflicts)
331
417
  return [] unless installed?
332
418
 
333
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
419
+ raw = writable_record
334
420
  raw["files"] ||= {}
335
421
  settled = conflicts.filter_map do |file_key, info|
336
422
  key = file_key.to_s
@@ -349,7 +435,7 @@ module Samagotchi
349
435
  # A missing file was put back with the bundle's bytes: +path+ is its base,
350
436
  # its entry is made or rebuilt (no mark), and the rest of the manifest stays.
351
437
  def record_restored(file_key, path, description: nil)
352
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
438
+ raw = writable_record
353
439
  content = File.binread(path)
354
440
  File.binwrite(base_path(file_key), content)
355
441
  entry = { "checksum" => Digest::SHA256.hexdigest(content) }
@@ -369,7 +455,7 @@ module Samagotchi
369
455
  def refresh_marks(marks, incoming_dir)
370
456
  return unless installed?
371
457
 
372
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
458
+ raw = writable_record
373
459
  marks.each do |mark|
374
460
  entry = raw.dig("files", mark.file)
375
461
  next unless entry&.fetch("conflict", nil)
@@ -393,7 +479,7 @@ module Samagotchi
393
479
  def note_merged(files, version:, trash:, at: Time.now)
394
480
  return unless installed?
395
481
 
396
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
482
+ raw = writable_record
397
483
  files.each do |key|
398
484
  raw.dig("files", key)&.merge!({ "merged_version" => version.to_s, "merged_at" => at.iso8601, "merged_trash" => trash }.compact)
399
485
  end
@@ -409,7 +495,7 @@ module Samagotchi
409
495
  def clear_merge_notes(notes, files: [])
410
496
  return if notes.empty? && files.empty?
411
497
 
412
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
498
+ raw = writable_record
413
499
  stale = notes.select do |note|
414
500
  entry = raw.dig("files", note.file)
415
501
  entry && entry["merged_version"] == note.version && entry["merged_at"] == note.at
@@ -419,6 +505,8 @@ module Samagotchi
419
505
  MERGE_NOTE_FIELDS.each { |field| entry.delete(field) } if entry
420
506
  end
421
507
  write_manifest(raw)
508
+ rescue StateFormat::InvalidDocument => e
509
+ e.message
422
510
  rescue JSON::ParserError, SystemCallError
423
511
  nil
424
512
  end
@@ -437,7 +525,10 @@ module Samagotchi
437
525
  def adopt_manifest(dir, dry_run: false)
438
526
  bundle = record
439
527
  return "#{@name} isn't installed" unless bundle
528
+
440
529
  return "#{@name} is a dev install" if bundle.dev
530
+
531
+ raw = writable_record
441
532
  return "#{@name} keeps its manifest already" unless bundle.manifest_unkept?
442
533
 
443
534
  source = File.join(dir.to_s, "manifest.yml")
@@ -449,9 +540,10 @@ module Samagotchi
449
540
  return why if why || dry_run
450
541
 
451
542
  AtomicFile.write(kept_manifest_path, bytes)
452
- raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
453
543
  write_manifest(raw.merge("manifest_sha" => "sha256:#{Digest::SHA256.hexdigest(bytes)}"))
454
544
  nil
545
+ rescue StateFormat::InvalidDocument => e
546
+ e.message
455
547
  rescue JSON::ParserError, SystemCallError => e
456
548
  "#{@name}'s record can't take it: #{e.message}"
457
549
  end
@@ -463,6 +555,32 @@ module Samagotchi
463
555
  InstalledBundle.read(@name, File.join(@bundle_dir, "manifest.json"))
464
556
  end
465
557
 
558
+ def ensure_supported_record_format!
559
+ format_record(read)
560
+ end
561
+
562
+ # The journal's memory => shas, {} when it doesn't belong to this target
563
+ # or doesn't read.
564
+ def pending_files(target_dir:)
565
+ return {} if target_dir.to_s.empty?
566
+
567
+ metadata = pending_metadata
568
+ return {} unless metadata && File.expand_path(target_dir.to_s) == metadata["target_dir"]
569
+
570
+ files = metadata["files"]
571
+ files.is_a?(Hash) ? files.to_h { |key, shas| [key, Array(shas).grep(String)] } : {}
572
+ end
573
+
574
+ def pending_metadata
575
+ data = JSON.parse(File.read(pending_path))
576
+ return unless data.is_a?(Hash) && data["scope"].is_a?(String) && data["target_dir"].is_a?(String)
577
+
578
+ data
579
+ rescue JSON::ParserError, EncodingError, SystemCallError
580
+ nil
581
+ end
582
+ private :pending_files, :pending_metadata
583
+
466
584
  # manifest.json as it is on disk, symbol keys (nil if not installed).
467
585
  # Readers take #record.
468
586
  def read
@@ -472,6 +590,40 @@ module Samagotchi
472
590
  JSON.parse(File.read(manifest_path), symbolize_names: true)
473
591
  end
474
592
 
593
+ def writable_record
594
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
595
+ format_record(raw)
596
+ raw
597
+ end
598
+ private :writable_record
599
+
600
+ def format_record(data)
601
+ return unless data
602
+ raise StateFormat::InvalidDocument, "manifest.json must contain a JSON object" unless data.is_a?(Hash)
603
+
604
+ data = data.transform_keys(&:to_s)
605
+ version = data["record_format"]
606
+ unless version.nil? || (version.is_a?(Integer) && version >= 0)
607
+ raise StateFormat::InvalidDocument, "manifest.json has an invalid record_format"
608
+ end
609
+
610
+ document = RECORD_FORMAT.read({ "version" => version, "records" => [data] }, collection: "records")
611
+ if document.version > RECORD_FORMAT_VERSION
612
+ raise StateFormat::InvalidDocument,
613
+ "manifest.json record_format #{document.version} is newer than supported #{RECORD_FORMAT_VERSION}; update chi before writing it"
614
+ end
615
+
616
+ record = document.entries.first
617
+ unless record&.readable?
618
+ raise StateFormat::InvalidDocument,
619
+ "manifest.json is missing required record fields; repair with chi bundle uninstall #{@name} --force, " \
620
+ "then chi bundle install <source> --force"
621
+ end
622
+
623
+ record
624
+ end
625
+ private :format_record
626
+
475
627
  # Write aside and rename, so a reader in another process (a parallel
476
628
  # chi start) never parses a truncated manifest.json.
477
629
  def write_manifest(manifest_data)
@@ -292,8 +292,9 @@ module Samagotchi
292
292
  # Merger::Upgrade#forecast.
293
293
  def forecast(row, bundle)
294
294
  shipped = Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort
295
+ files = Provenance.new(name: row.name).files_with_pending(bundle, target_dir: MemoryPaths.scope_dir!(row.scope))
295
296
  Merger::Upgrade.new(provenance: Provenance.new(name: row.name), target_dir: MemoryPaths.scope_dir!(row.scope),
296
- source_dir: row.source_dir).forecast(shipped & bundle.files.keys, version: row.to)
297
+ source_dir: row.source_dir).forecast(shipped & files.keys, version: row.to)
297
298
  end
298
299
 
299
300
  # What the upgrade from +source_dir+ copies to the trash, as
@@ -39,7 +39,7 @@ module Samagotchi
39
39
 
40
40
  scope = (scope_override ? bundle.with(scope: scope_override) : bundle).effective_scope
41
41
  target_dir = MemoryPaths.scope_dir(scope)
42
- files = bundle.files
42
+ files = provenance.files_with_pending(bundle, target_dir: target_dir)
43
43
  details = {}
44
44
  behind = bundle.behind_files(target_dir).to_h { |file| [file.file, file] }
45
45
  notes = bundle.merge_notes.to_h { |note| [note.file, note] }
@@ -84,6 +84,8 @@ module Samagotchi
84
84
  # Those notes have been shown (Provenance#clear_merge_notes), under the lock.
85
85
  def clear_merge_notes(notes, files: [])
86
86
  with_lock { Provenance.new(name: BUNDLE_NAME).clear_merge_notes(notes, files: files) }
87
+ rescue StateFormat::InvalidDocument => e
88
+ e.message
87
89
  rescue StandardError
88
90
  nil
89
91
  end