samagotchi 0.159.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # Installs a bundle into a scoped memories directory.
21
21
  #
22
22
  # Flow (#run):
23
- # 1. Normalize source → directory, read manifest.yml (load_source)
23
+ # 1. Normalize source → directory, refuse retired hooks, read manifest.yml (load_source)
24
24
  # 2. Refuse a bundle whose requires_chi this chi doesn't meet,
25
25
  # unless force (refuse_too_new)
26
26
  # 3. Copy .md files to the target scope dir and update index.md
@@ -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
@@ -356,7 +417,7 @@ module Samagotchi
356
417
  @bundle_dir = @source_dir = prepared.dir
357
418
  @source_owned = prepared.owned
358
419
  @source_commit = prepared.commit
359
- refuse_hooks
420
+ Manifest::HooksRetired.refuse_source_files(@bundle_dir)
360
421
  @manifest = Manifest.read(dir: @bundle_dir)
361
422
  clash = @manifest.session_settings_clash
362
423
  raise InstallError, clash if clash
@@ -371,15 +432,6 @@ module Samagotchi
371
432
  @warnings << "No manifest found — proceeding without strict manifest validation"
372
433
  end
373
434
 
374
- # A source that ships class-file hooks (hooks/*.rb, listed or not) is
375
- # refused before its manifest is read; its manifest's hooks: is
376
- # Manifest.read's refusal (Manifest::HooksRetired either way).
377
- # @raise [Manifest::HooksRetired]
378
- def refuse_hooks
379
- files = Manifest::HooksRetired.files_in(@bundle_dir)
380
- raise Manifest::HooksRetired.for_files(files) unless files.empty?
381
- end
382
-
383
435
  # A bundle whose requires_chi this chi doesn't meet is not installed at
384
436
  # all: --force is what installs it, and its plugin and rules then
385
437
  # warn that they won't load (AssetInstaller). The words come from
@@ -393,6 +445,12 @@ module Samagotchi
393
445
  raise InstallError, "#{@name} can't be installed: #{failure} (chi update, or --force to install it anyway)"
394
446
  end
395
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
+
396
454
  # The scope the files go to (the CLI's wins over the manifest's) and
397
455
  # its dir, created.
398
456
  # @return [Array(String, String)] scope, dir
@@ -557,6 +615,7 @@ module Samagotchi
557
615
  # cell's text merge runs on temp copies). --force would install every
558
616
  # file, unread.
559
617
  def preview_memory(file_key, state, action)
618
+ preview_trash_copy(file_key, state, action)
560
619
  return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
561
620
  return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
562
621
 
@@ -564,9 +623,20 @@ module Samagotchi
564
623
  action = decision.action if decision
565
624
  return note_conflict(file_key, decision.outcome, state) if action.behind?
566
625
 
626
+ preview_trash_copy(file_key, state, action)
567
627
  @results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
568
628
  end
569
629
 
630
+ def preview_trash_copy(file_key, state, action)
631
+ return unless action.save_first
632
+
633
+ current = state.paths[:current]
634
+ incoming = state.paths[:incoming]
635
+ return unless File.file?(current) && !FileUtils.identical?(incoming, current)
636
+
637
+ @warnings << "Would copy to the trash first: #{file_key}"
638
+ end
639
+
570
640
  # Strict mode: a memory that takes the bundle's text (Action#takes_source?:
571
641
  # a new or missing file, --force, an upgrade's or a re-install's
572
642
  # fast-forward, a merge into a local edit) warns when the bundle's file
@@ -782,8 +852,8 @@ module Samagotchi
782
852
 
783
853
  # Not fatal, like update_target_index: a warning says so.
784
854
  def remove_target_index(scope, file_key)
785
- IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
786
- 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
787
857
  rescue StandardError => e
788
858
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not removed (#{e.message})"
789
859
  end
@@ -83,6 +83,12 @@ module Samagotchi
83
83
 
84
84
  # The source's hooks/*.rb, relative and sorted ([] for none).
85
85
  def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
86
+
87
+ # Refuse a source's class-file hooks before parsing its manifest.
88
+ def self.refuse_source_files(dir)
89
+ files = files_in(dir)
90
+ raise for_files(files) unless files.empty?
91
+ end
86
92
  end
87
93
 
88
94
  # A plugin file is a plain .rb name in the bundle's top directory.
@@ -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)
@@ -52,7 +52,8 @@ module Samagotchi
52
52
  # mismatch warnings. merged and replaced are file
53
53
  # names, behind FileStanding::Behind; under plan they are what apply would
54
54
  # merge, replace or leave behind.
55
- # adds is nil but for a profile: the members it installs.
55
+ # added_files is the new version's memory files the bundle didn't own
56
+ # before; adds is nil but for a profile: the members it installs.
56
57
  # kept_own is nil but for a profile that installed a member: its
57
58
  # Profile.kept_own_line lines.
58
59
  # trash_copy is nil but under apply when it saved edited files
@@ -62,8 +63,8 @@ module Samagotchi
62
63
  # manifest: what a row at the shipped version says of its manifest.yml
63
64
  # (ManifestStanding.keep's line: would keep or kept it, or why not and
64
65
  # the command), nil when nothing.
65
- Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :adds, :kept_own,
66
- :trash_copy, :earlier, :manifest, keyword_init: true) do
66
+ Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :added_files, :adds,
67
+ :kept_own, :trash_copy, :earlier, :manifest, keyword_init: true) do
67
68
  def kept = Array(behind).map(&:file)
68
69
  end
69
70
 
@@ -208,6 +209,7 @@ module Samagotchi
208
209
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
209
210
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
210
211
 
212
+ row.added_files = (manifest.files.keys - bundle.files.keys).sort
211
213
  forecast = forecast(row, bundle)
212
214
  row.merged = forecast.merged
213
215
  row.behind = forecast.behind
@@ -290,8 +292,9 @@ module Samagotchi
290
292
  # Merger::Upgrade#forecast.
291
293
  def forecast(row, bundle)
292
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))
293
296
  Merger::Upgrade.new(provenance: Provenance.new(name: row.name), target_dir: MemoryPaths.scope_dir!(row.scope),
294
- 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)
295
298
  end
296
299
 
297
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,12 +84,12 @@ module Samagotchi
84
84
  private_class_method :digest_or_nil
85
85
 
86
86
  # The class-file hooks the record still has (a 0.157 install's; chi
87
- # loads none, Manifest::HooksRetired), each "ok" or "missing" in
87
+ # loads none, Manifest::HooksRetired), each "present" or "missing" in
88
88
  # hooks/, by name; {} for none.
89
89
  # @return [Hash{String => String}]
90
90
  def self.retired_hooks(provenance, bundle)
91
91
  bundle.hooks.keys.sort.to_h do |file|
92
- [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "ok" : "missing"]
92
+ [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "present" : "missing"]
93
93
  end
94
94
  end
95
95
 
@@ -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
@@ -44,18 +44,30 @@ module Samagotchi
44
44
  def run
45
45
  provenance = Provenance.new(name: @name)
46
46
  bundle = provenance.record
47
- raise UninstallError.not_installed(@name) unless bundle
48
-
49
- # The scope from the flag, else the record's
50
- target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
51
- target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
47
+ if bundle
48
+ target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
49
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
50
+ else
51
+ target_scope = @scope.to_s.strip.empty? ? provenance.pending_scope : @scope
52
+ pending_target = provenance.pending_target_dir
53
+ raise UninstallError.not_installed(@name) unless target_scope && pending_target
54
+
55
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
56
+ if @scope.to_s.strip.empty?
57
+ target_dir = pending_target
58
+ elsif File.expand_path(target_dir) != pending_target
59
+ raise UninstallError.not_installed(@name)
60
+ end
61
+ end
52
62
 
53
- files = bundle.files
54
- hooks = bundle.hooks
63
+ files = provenance.files_with_pending(bundle, target_dir: target_dir)
64
+ hooks = bundle&.hooks || {}
55
65
  if files.empty? && hooks.empty?
56
66
  # No file list, just remove provenance (rules, a plugin and scripts
57
67
  # live there too)
58
68
  save_edits(provenance, bundle)
69
+ plugin_path = provenance.plugin_path(bundle)
70
+ @removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
59
71
  FileUtils.rm_rf(provenance.bundle_dir)
60
72
  return true
61
73
  end
@@ -96,7 +108,7 @@ module Samagotchi
96
108
 
97
109
  @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
98
110
  unless ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
99
- retag(target_scope, target_path, file_key_str, others.first)
111
+ retag(target_scope, target_path, file_key_str, others.first, target_dir: target_dir)
100
112
  end
101
113
  next
102
114
  end
@@ -106,8 +118,8 @@ module Samagotchi
106
118
  end
107
119
  # Remove index line (not fatal: a warning says so)
108
120
  begin
109
- IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
110
- IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
121
+ IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"), target_dir: target_dir)
122
+ IndexUpdater.remove_index(target_scope, file_key_str, target_dir: target_dir) # legacy "name.md" line
111
123
  rescue StandardError => e
112
124
  @warnings << "index.md: line for #{file_key_str.delete_suffix(".md")} not removed (#{e.message})"
113
125
  end
@@ -149,6 +161,8 @@ module Samagotchi
149
161
  # scripts by their source-less AssetState and ASSET_UNINSTALL's
150
162
  # save_first; an older chi's hooks/ by Trash#copy_edits.
151
163
  def save_edits(provenance, bundle)
164
+ return unless bundle
165
+
152
166
  @trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
153
167
  { rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
154
168
  script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
@@ -159,9 +173,11 @@ module Samagotchi
159
173
  end
160
174
 
161
175
  # A kept shared file's index line names the bundle that still has it
162
- # (a model overlay has none: the caller skips it).
163
- def retag(scope, path, file_key, bundle)
164
- IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
176
+ # (a model overlay has none: the caller skips it). +target_dir+ is the
177
+ # folder the file lives in, which for an install interrupted before its
178
+ # record is the journal's target dir, not the current folder's.
179
+ def retag(scope, path, file_key, bundle, target_dir:)
180
+ IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle, target_dir: target_dir)
165
181
  rescue StandardError => e
166
182
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
167
183
  end