samagotchi 0.151.0 → 0.152.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/bundle-authoring.md +4 -4
  4. data/docs/cli.md +32 -7
  5. data/docs/guardrails.md +4 -0
  6. data/docs/internals/block-renderers.md +3 -1
  7. data/docs/internals/bundles.md +48 -14
  8. data/docs/internals/objectives.md +6 -4
  9. data/docs/memory.md +6 -1
  10. data/docs/plugins.md +51 -14
  11. data/docs/releasing.md +7 -2
  12. data/docs/sessions.md +1 -1
  13. data/lib/samagotchi/block_renderers.rb +9 -8
  14. data/lib/samagotchi/bundle_command/check.rb +43 -3
  15. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  16. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  17. data/lib/samagotchi/bundle_command.rb +88 -10
  18. data/lib/samagotchi/bundle_needs.rb +4 -6
  19. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  20. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  28. data/lib/samagotchi/child_env.rb +15 -0
  29. data/lib/samagotchi/context_providers.rb +42 -10
  30. data/lib/samagotchi/engine.rb +27 -4
  31. data/lib/samagotchi/extension_load.rb +4 -3
  32. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  33. data/lib/samagotchi/guardrails/approval.rb +2 -2
  34. data/lib/samagotchi/guardrails/rules.rb +1 -0
  35. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  37. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  38. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  39. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  40. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  41. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  42. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  43. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  44. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  45. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  46. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  47. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  48. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  49. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  50. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  51. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  53. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  55. data/lib/samagotchi/plugin/loader.rb +1 -1
  56. data/lib/samagotchi/session_config.rb +5 -4
  57. data/lib/samagotchi/system_one/record.rb +10 -2
  58. data/lib/samagotchi/tools/delegate.rb +20 -2
  59. data/lib/samagotchi/update_command.rb +13 -2
  60. data/lib/samagotchi/version.rb +1 -1
  61. data/lib/samagotchi/web/app.rb +2 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  63. data/lib/samagotchi/web/session_hub.rb +6 -0
  64. metadata +4 -1
@@ -48,6 +48,21 @@ module Samagotchi
48
48
  # @return [Hash]
49
49
  def scrub(env) = env.except(*spawner_keys(env))
50
50
 
51
+ # +env+ as a spawn argument that really removes what it leaves out. A Hash
52
+ # given to Process.spawn, Open3 or exec is merged onto the spawner's
53
+ # environment: a key only deleted from it is still inherited by the child.
54
+ # Every key +spawner+ has and +env+ doesn't is mapped to nil, which
55
+ # Process.spawn unsets, so the child sees exactly +env+ (and #scrub is
56
+ # safe to use with it). +env+ is not changed.
57
+ # @param env [Hash]
58
+ # @param spawner [Hash] the spawner's environment (default: ENV)
59
+ # @return [Hash]
60
+ def spawn_env(env, spawner: ENV)
61
+ out = env.dup
62
+ spawner.each_key { |key| out[key] = nil unless out.key?(key) }
63
+ out
64
+ end
65
+
51
66
  # In gem mode, what Bundler changed in +env+ mapped back
52
67
  # (InstalledGem.unbundled_env_changes): a child activates the installed
53
68
  # gem itself rather than inherit this app's bundle. {} in a checkout.
@@ -46,6 +46,11 @@ module Samagotchi
46
46
  RUBY = "{ruby}"
47
47
  URL = "{url}"
48
48
  MIN_EVERY_SECONDS = 30
49
+ # A bundle whose requires_chi this chi doesn't meet, already logged: said
50
+ # once per process per bundle and version, the way GuardrailWiring says a
51
+ # bundle's kept rules are.
52
+ TOO_NEW_LOGGED = Set.new
53
+ TOO_NEW_LOG_LOCK = Mutex.new
49
54
 
50
55
  module_function
51
56
 
@@ -79,15 +84,16 @@ module Samagotchi
79
84
 
80
85
  # The source the installed bundles' providers make of +url+: the first
81
86
  # bundle (by name) whose provider matches. A bundle whose providers
82
- # don't parse is skipped (logged).
87
+ # don't parse, or whose requires_chi this chi doesn't meet, is skipped
88
+ # (logged).
83
89
  # @return [Resolved, nil] nil when none matches
84
90
  # @raise [Invalid] the provider makes a name that isn't a source name
85
91
  def resolve(url)
86
92
  url = url.to_s.strip
87
93
  MemoryBundle::Provenance.each_installed do |bundle, record|
88
- next if record.error? || !record.context_providers?
94
+ next if record.error? || too_new?(bundle, record.manifest)
89
95
 
90
- providers(bundle, record).each do |provider|
96
+ providers(bundle, record.manifest).each do |provider|
91
97
  match = provider.regexp.match(url) or next
92
98
  return resolved(bundle, provider, match)
93
99
  end
@@ -97,14 +103,33 @@ module Samagotchi
97
103
 
98
104
  # Whether an installed bundle declares a provider (the web's "+ URL").
99
105
  def any?
100
- MemoryBundle::Provenance.each_installed.any? { |_bundle, record| !record.error? && record.context_providers? }
106
+ MemoryBundle::Provenance.each_installed.any? do |bundle, record|
107
+ !record.error? && !too_new?(bundle, record.manifest) && record.manifest.context_providers?
108
+ end
101
109
  end
102
110
 
103
- def providers(bundle, record)
104
- parse_list(record.context_providers)
105
- rescue Invalid => e
106
- Log.warn(:context, "providers_unreadable", bundle: bundle, msg: e.message)
107
- []
111
+ # A bundle this chi is too old for has no usable providers: skip it, and
112
+ # say so once per process per bundle and version.
113
+ # @return [Boolean]
114
+ def too_new?(bundle, manifest)
115
+ return false unless manifest.context_providers?
116
+
117
+ failure = manifest.requires_chi_failure
118
+ return false if failure.nil?
119
+
120
+ if TOO_NEW_LOG_LOCK.synchronize { TOO_NEW_LOGGED.add?([bundle, manifest.version.to_s]) }
121
+ Log.warn(:context, "providers_too_new", bundle: bundle, version: manifest.version.to_s,
122
+ requires_chi: manifest.requires_chi.to_s, msg: failure)
123
+ end
124
+ true
125
+ end
126
+
127
+ # Its parsed providers; a list that doesn't parse is none (logged).
128
+ def providers(bundle, manifest)
129
+ if (problem = manifest.problems["context_providers"])
130
+ Log.warn(:context, "providers_unreadable", bundle: bundle, msg: problem)
131
+ end
132
+ manifest.context_providers
108
133
  end
109
134
 
110
135
  def resolved(bundle, provider, match)
@@ -124,7 +149,8 @@ module Samagotchi
124
149
  # installed (they run outside the guardrails gate, as a plugin loads:
125
150
  # checked the same way).
126
151
  # @return [String]
127
- # @raise [Invalid] the bundle is gone or a script changed
152
+ # @raise [Invalid] the bundle is gone, a script changed, or this chi
153
+ # doesn't meet the bundle's requires_chi
128
154
  def command_for(source)
129
155
  return source.cmd unless source.provider
130
156
 
@@ -132,6 +158,12 @@ module Samagotchi
132
158
  record = provenance.record
133
159
  raise Invalid, "bundle #{source.provider} isn't installed" unless record
134
160
 
161
+ # Its bundle is too new for this chi (installed with --force, or chi
162
+ # was downgraded): its script won't run, as its plugin wouldn't load.
163
+ if (failure = record.manifest.requires_chi_failure)
164
+ raise Invalid, "bundle #{source.provider}: #{failure}"
165
+ end
166
+
135
167
  record.scripts.each do |file, sha|
136
168
  path = File.join(provenance.scripts_dir, file)
137
169
  next if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, sha)
@@ -1037,8 +1037,7 @@ module Samagotchi
1037
1037
  end
1038
1038
 
1039
1039
  def show_objective_proposals
1040
- proposals = @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1041
- proposals.each do |proposal|
1040
+ drain_objective_proposals.each do |proposal|
1042
1041
  id = "objectives-#{SecureRandom.hex(4)}"
1043
1042
  show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
1044
1043
  actions: [{ label: "Apply", command: proposal.action_line(id) },
@@ -1049,6 +1048,22 @@ module Samagotchi
1049
1048
  end
1050
1049
  private :show_objective_proposals
1051
1050
 
1051
+ # A turn that did not complete (canceled, failed) takes its proposals
1052
+ # off the buffer without showing them: their Apply card would sit after a
1053
+ # turn whose `objectives propose` call is not in the history (a rollback
1054
+ # after Ctrl-C drops it), and leaving them in the buffer would show them
1055
+ # at the end of the next turn.
1056
+ def discard_objective_proposals(turn)
1057
+ dropped = drain_objective_proposals
1058
+ Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
1059
+ end
1060
+ private :discard_objective_proposals
1061
+
1062
+ def drain_objective_proposals
1063
+ @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1064
+ end
1065
+ private :drain_objective_proposals
1066
+
1052
1067
  # The strategy the working set is built under now, as LLMContextView
1053
1068
  # takes it: the running turn's, else the next turn's (#emit_working_set).
1054
1069
  # /working-set's ops act under it.
@@ -1748,6 +1763,7 @@ module Samagotchi
1748
1763
  # no Revise on its answer, verdict or not, as a reload does).
1749
1764
  def end_turn(turn, outcome, save: false, exhausted: false)
1750
1765
  seconds = turn.elapsed
1766
+ turn.outcome = outcome
1751
1767
  synchronize_events do
1752
1768
  kept, event = yield(seconds)
1753
1769
  event[:turn_id] = turn.id
@@ -2322,9 +2338,13 @@ module Samagotchi
2322
2338
  # :turn_started event's at and its user message's (and reminders') stamp.
2323
2339
  # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
2324
2340
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2325
- :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
2341
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
2326
2342
  def resumed? = resumed ? true : false
2327
2343
 
2344
+ # The turn ended as a completed one (not canceled, not failed): the
2345
+ # test the between-turns cards of it (an objectives proposal) use.
2346
+ def completed? = outcome == "completed"
2347
+
2328
2348
  # The turn's boundary events carry the origin only when it names a
2329
2349
  # sender (not TurnOrigin::NONE), so payloads stay unchanged for callers
2330
2350
  # that don't pass one; as its wire Hash (TurnOrigin#to_h), which the
@@ -2448,7 +2468,10 @@ module Samagotchi
2448
2468
  ensure
2449
2469
  release_turn(probe_cancel_before)
2450
2470
  show_context_cards_after_turn
2451
- show_objective_proposals
2471
+ # Only a completed turn shows its proposal cards: a canceled, failed
2472
+ # or rolled-back turn keeps none, and its proposals are dropped here
2473
+ # so the next turn does not show them either.
2474
+ turn.completed? ? show_objective_proposals : discard_objective_proposals(turn)
2452
2475
  end
2453
2476
  end
2454
2477
 
@@ -87,13 +87,14 @@ module Samagotchi
87
87
  end
88
88
  bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
89
89
  hooks_dir = File.join(bundle_dir, "hooks")
90
- if bundle.experimental?
90
+ manifest = bundle.manifest
91
+ if manifest.experimental?
91
92
  Log.info(:hooks, "experimental_bundle", echo: "[hooks] Bundle '#{bundle_name}' is experimental — its hooks may change or misbehave.", bundle: bundle_name)
92
93
  end
93
94
  begin
94
- Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: bundle.hooks, registry: registry,
95
+ Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: manifest.hooks, registry: registry,
95
96
  failures: @hook_failures, settings: settings[bundle_name.to_s] || {},
96
- requires_chi: bundle.requires_chi, hook_failed: @hook_failed)
97
+ manifest: manifest, hook_failed: @hook_failed)
97
98
  rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
98
99
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
99
100
 
@@ -228,8 +228,9 @@ module Samagotchi
228
228
  @failures.add("rules (bundle #{bundle_name})", bundle.error, required: true, group: :rules)
229
229
  next
230
230
  end
231
- if (too_new = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION))
232
- rules.concat(too_new_bundle_rules(bundle_name, bundle, too_new))
231
+ manifest = bundle.manifest
232
+ if (too_new = manifest.requires_chi_failure)
233
+ rules.concat(too_new_bundle_rules(bundle_name, manifest, too_new))
233
234
  next
234
235
  end
235
236
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
@@ -323,7 +324,8 @@ module Samagotchi
323
324
  # one that passes every gate is allowed once without reaching them
324
325
  # (docs/guardrails.md, "System 1").
325
326
  judgment = begin
326
- system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
327
+ system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?,
328
+ other_rules: verdict.other_ask_rules)
327
329
  rescue LLM::RequestCancelled
328
330
  return turn_stopped(verdict)
329
331
  end
@@ -367,8 +369,9 @@ module Samagotchi
367
369
  # host) means no line at all. A judgment that fails is logged with p nil,
368
370
  # so the failures are in the log too.
369
371
  # With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
372
+ # +other_rules+ are the ids of the other rules asking about the same call.
370
373
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
371
- def system_one_judge(payload, targets: nil, ask_user: false)
374
+ def system_one_judge(payload, targets: nil, ask_user: false, other_rules: nil)
372
375
  return nil if Steer.participant_author?(turn_author)
373
376
 
374
377
  client = @system_one.call
@@ -401,12 +404,16 @@ module Samagotchi
401
404
  auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
402
405
  decision = system_one_decision(client, auto)
403
406
 
404
- id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
405
- rule: approval.rule, source: approval.source, tool: approval.tool,
406
- **SystemOne::ApprovalQuestion.call_fields(approval),
407
- repo_name: approval.repo_name, repo: targets&.repo,
408
- p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
409
- latency_ms: latency_ms, decision: decision, origin: "live")
407
+ fields = { session_id: @session_lookup.call&.id, rule: approval.rule, source: approval.source,
408
+ tool: approval.tool, **SystemOne::ApprovalQuestion.call_fields(approval),
409
+ repo_name: approval.repo_name, repo: targets&.repo, p: p,
410
+ threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
411
+ latency_ms: latency_ms, decision: decision, origin: "live" }
412
+ # A call two rules ask about names only one; the others ride along so
413
+ # each rule's record (SystemOne::Record) counts this ask.
414
+ others = Array(other_rules).map(&:to_s).reject(&:empty?) - [approval.rule.to_s]
415
+ fields[:other_rules] = others unless others.empty?
416
+ id = log.judgment(id: run_id, **fields)
410
417
  # A judgment the log cannot hold is no judgment: the user is asked.
411
418
  system_one_run_facts(run_id, approval, targets, id.nil? && auto ? "asked" : decision)
412
419
  { id: id, p: p, auto: auto && !id.nil? }
@@ -520,7 +527,8 @@ module Samagotchi
520
527
  # load the new ones). Never loaded (a chi older than the bundle it
521
528
  # starts with): none, and a required load failure, as the hook loader
522
529
  # does for a guardrail hook, so the calls aren't quietly unguarded.
523
- def too_new_bundle_rules(bundle_name, bundle, reason)
530
+ # @param manifest [MemoryBundle::InstalledManifest]
531
+ def too_new_bundle_rules(bundle_name, manifest, reason)
524
532
  kept = @loaded_bundle_rules[bundle_name]
525
533
  unless kept
526
534
  Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
@@ -530,29 +538,30 @@ module Samagotchi
530
538
  return []
531
539
  end
532
540
 
533
- key = [bundle_name, bundle.version.to_s]
541
+ key = [bundle_name, manifest.version.to_s]
534
542
  return kept if @kept_noticed[key]
535
543
 
536
544
  @kept_noticed[key] = true
537
- Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: bundle.version.to_s,
538
- requires_chi: bundle.requires_chi.to_s)
539
- @notify.call(kept_notice(bundle_name, bundle))
545
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: manifest.version.to_s,
546
+ requires_chi: manifest.requires_chi.to_s)
547
+ @notify.call(kept_notice(bundle_name, manifest))
540
548
  kept
541
549
  end
542
550
 
543
- def kept_notice(bundle_name, bundle)
551
+ def kept_notice(bundle_name, manifest)
544
552
  id = @session_lookup.call&.id || "ID"
545
- "bundle #{bundle_name} #{bundle.version} needs chi #{bundle.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
553
+ "bundle #{bundle_name} #{manifest.version} needs chi #{manifest.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
546
554
  "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
547
555
  "(chi sessions stop #{id}, then chi --resume #{id})"
548
556
  end
549
557
 
550
558
  # [path, mtime, size] of config.yml and every installed bundle's
551
- # manifest.json and guardrails/ file.
559
+ # manifest.json, kept manifest.yml (its requires_chi is read from there)
560
+ # and guardrails/ file.
552
561
  def rules_stamp
553
562
  require_relative "memory_bundle/provenance"
554
563
  paths = [Samagotchi::ConfigFile.global_path] +
555
- Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
564
+ Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,manifest.yml,guardrails/*}")]
556
565
  paths.compact.sort.map do |path|
557
566
  stat = File.stat(path)
558
567
  [path, stat.mtime.to_r, stat.size]
@@ -213,8 +213,8 @@ module Samagotchi
213
213
  end
214
214
  end
215
215
 
216
- # What a session answer covers, as Approvals.key_for keys it: a file
217
- # tool's later calls on the same paths, else this one exact call.
216
+ # What a session answer covers, as Guardrails::Approvals.key_for keys
217
+ # it: a file tool's later calls on the same paths, else this one exact call.
218
218
  def session_subject(targets)
219
219
  return "this call" if targets.nil? || targets.command || targets.paths.empty?
220
220
 
@@ -325,6 +325,7 @@ module Samagotchi
325
325
  if rule.verdict == :deny
326
326
  verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
327
327
  else
328
+ verdict.note_ask_rule(rule.id)
328
329
  verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
329
330
  verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
330
331
  asking ||= rule.id
@@ -63,6 +63,26 @@ module Samagotchi
63
63
  @note = nil
64
64
  @voter = nil
65
65
  @advice = nil
66
+ @ask_rules = []
67
+ end
68
+
69
+ # Every rule id that asked about this call, in the order they voted
70
+ # (Rules#check), including one whose vote did not take over. The ask
71
+ # names a single rule (#rule); the rest are #other_ask_rules.
72
+ attr_reader :ask_rules
73
+
74
+ # A rule that asks, recorded whatever became of its vote.
75
+ def note_ask_rule(rule)
76
+ id = rule.to_s
77
+ @ask_rules << id unless id.empty? || @ask_rules.include?(id)
78
+ self
79
+ end
80
+
81
+ # The rules that asked besides the one the ask names: nil when only one
82
+ # rule asked, so a judgment line carries nothing extra.
83
+ def other_ask_rules
84
+ others = @ask_rules - [rule.to_s]
85
+ others.empty? ? nil : others
66
86
  end
67
87
 
68
88
  # @param rule [String, nil] the rule id, when a rule decided
@@ -6,8 +6,6 @@ require_relative "../log"
6
6
  require_relative "../guardrails/load_failures"
7
7
  require_relative "../memory_bundle/bundle_hook"
8
8
  require_relative "../memory_bundle/provenance"
9
- require_relative "../memory_bundle/manifest"
10
- require_relative "../version"
11
9
 
12
10
  module Samagotchi
13
11
  # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
@@ -57,17 +55,17 @@ module Samagotchi
57
55
  # @param settings [Hash] the bundle's section of config.yml
58
56
  # `bundles:` (string keys); a hook class whose initialize takes an
59
57
  # argument gets it
60
- # @param requires_chi [String, nil] the bundle's requirement; when
61
- # this chi doesn't meet it no hook loads (each is reported, as the
62
- # plugin loader does)
58
+ # @param manifest [MemoryBundle::InstalledManifest, nil] the installed
59
+ # bundle's manifest; when this chi doesn't meet its requires_chi no
60
+ # hook loads (each is reported, as the plugin loader does)
63
61
  # @param hook_failed [#call, nil] (label, "Class: message", effect:)
64
62
  # when a hook file raises at fire time — the Engine's HookRoutes;
65
63
  # nil when there is no UI to tell (the test kit)
66
64
  # @return [Integer] number of hooks successfully registered
67
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil, hook_failed: nil)
65
+ def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, manifest: nil, hook_failed: nil)
68
66
  return 0 unless metadata.is_a?(Hash)
69
67
 
70
- too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
68
+ too_old = manifest&.requires_chi_failure
71
69
  loaded = 0
72
70
  metadata.each do |raw_basename, raw_meta|
73
71
  basename = raw_basename.to_s
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "digest"
4
+ require_relative "file_digest"
5
5
  require_relative "bundle_hook"
6
6
  require_relative "manifest"
7
7
  require_relative "provenance"
@@ -36,8 +36,10 @@ module Samagotchi
36
36
  # @param results, warnings, conflicts the Installer's (results and
37
37
  # warnings are added to)
38
38
  # @param trash [Trash] the Installer's
39
+ # @param chi_version [String] the chi this install is for (the Installer's;
40
+ # a caller that checks its own bundles against another version passes it)
39
41
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
40
- force: false, strict: true, upgrade: false, dry_run: false, dev: false)
42
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION)
41
43
  @name = name
42
44
  @bundle_dir = source_dir
43
45
  @manifest = manifest
@@ -52,6 +54,7 @@ module Samagotchi
52
54
  @upgrade = upgrade
53
55
  @dry_run = dry_run
54
56
  @dev = dev
57
+ @chi_version = chi_version
55
58
  end
56
59
 
57
60
  # @return [Installed]
@@ -175,7 +178,7 @@ module Samagotchi
175
178
  dest = File.join(@provenance.hooks_dir, basename)
176
179
  next unless File.exist?(dest)
177
180
 
178
- actual = Digest::SHA256.hexdigest(File.read(dest))
181
+ actual = FileDigest.hex(dest)
179
182
  if actual != expected
180
183
  @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
181
184
  end
@@ -229,7 +232,7 @@ module Samagotchi
229
232
  raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
230
233
  raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
231
234
 
232
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
235
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
233
236
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
234
237
  end
235
238
  if @dry_run
@@ -251,7 +254,7 @@ module Samagotchi
251
254
  { status: "installed" }
252
255
  end
253
256
  expected = @manifest.checksum_for_plugin
254
- actual = Digest::SHA256.hexdigest(File.binread(dest))
257
+ actual = FileDigest.hex(dest)
255
258
  if @strict && !@dev && expected && actual != expected
256
259
  @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
257
260
  end
@@ -291,7 +294,7 @@ module Samagotchi
291
294
  else { status: "installed" }
292
295
  end
293
296
  FileUtils.chmod(0o755, dest) unless @dev
294
- actual = Digest::SHA256.hexdigest(File.binread(dest))
297
+ actual = FileDigest.hex(dest)
295
298
  expected = declared.to_s.delete_prefix("sha256:")
296
299
  if @strict && !@dev && !expected.empty? && actual != expected
297
300
  @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
@@ -341,7 +344,7 @@ module Samagotchi
341
344
  def warn_hooks_requires_chi
342
345
  return if @manifest.nil? || @bundle_hooks.empty?
343
346
 
344
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
347
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
345
348
  @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
346
349
  end
347
350
  end
@@ -353,7 +356,7 @@ module Samagotchi
353
356
  def warn_rules_requires_chi(rule_files)
354
357
  return if @manifest.nil? || rule_files.empty?
355
358
 
356
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
359
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
357
360
  @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
358
361
  "(chi update), guarded tool calls are refused"
359
362
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require_relative "file_digest"
4
5
  require "fileutils"
5
6
  require "tmpdir"
6
7
  require_relative "bundle_hook"
7
8
  require_relative "manifest"
8
9
  require_relative "placeholder"
9
- require_relative "../context_providers"
10
10
  require_relative "../memory_paths"
11
11
  require_relative "../model_overlay"
12
12
 
@@ -140,7 +140,8 @@ module Samagotchi
140
140
 
141
141
  # The installed bundle's hooks (a recorded one missing on disk left
142
142
  # out; one with no recorded sha gets its file's), guardrail rules,
143
- # plugin, scripts and context providers. Without hooks or rules in a
143
+ # plugin, scripts, context providers and block renderers (its
144
+ # InstalledManifest's). Without hooks or rules in a
144
145
  # record, the scope dir's hooks/*.rb and guardrails/*.yml (building
145
146
  # from a raw bundle dir that is also the memories dir).
146
147
  # @return [InstalledParts]
@@ -174,13 +175,18 @@ module Samagotchi
174
175
 
175
176
  rules_dir = record&.guardrails&.any? ? prov.guardrails_dir : File.join(target_dir, "guardrails")
176
177
  plugin = record ? prov.plugin_path(record) : nil
178
+ manifest = record&.manifest
179
+ # A key of the record that doesn't parse isn't built as empty: the build refuses, as it always has.
180
+ problem = manifest&.key_problems&.values&.first
181
+ raise BuildError, "the installed #{name}'s record: #{problem}" if problem
182
+
177
183
  InstalledParts.new(
178
184
  record: record, hooks: hooks, hook_files: hook_files,
179
185
  rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
180
186
  plugin: plugin && File.file?(plugin) ? plugin : nil,
181
187
  scripts: installed_scripts(prov, record),
182
- context_providers: record ? ContextProviders.parse_list(record.context_providers) : [],
183
- block_renderers: record ? BlockRenderers.parse_list(record.block_renderers) : []
188
+ context_providers: manifest ? manifest.context_providers : [],
189
+ block_renderers: manifest ? manifest.block_renderers : []
184
190
  )
185
191
  end
186
192
 
@@ -204,33 +210,45 @@ module Samagotchi
204
210
  end
205
211
 
206
212
  # manifest.yml for the staged bundle: requires_chi, needs, session_settings and settings (typed) and the
207
- # file descriptions come from the installed bundle's record, the
208
- # trust_level from the CLI else the record.
213
+ # file descriptions come from the installed bundle's manifest (InstalledManifest), the
214
+ # trust_level and description from the CLI else that manifest. A key of its kept manifest.yml
215
+ # this chi doesn't know is carried as installed (the build is the bundle again), with a warning.
209
216
  def write_manifest(staging, name, version, files_map, parts)
210
- record = parts.record
217
+ manifest = parts.record&.manifest
211
218
  trust_level = @trust_level
212
- trust_level = record.trust_level if (trust_level.nil? || trust_level.empty?) && record&.trust_level
219
+ trust_level = manifest.trust_level if (trust_level.nil? || trust_level.empty?) && manifest&.trust_level
220
+ description = @description.empty? && manifest&.description ? manifest.description : @description
213
221
  Manifest.write(
214
222
  dir: staging,
215
223
  name: name,
216
224
  version: version,
217
225
  scope: @scope,
218
- description: @description,
226
+ description: description,
219
227
  files: files_map,
220
228
  hooks: parts.hooks.empty? ? nil : parts.hooks,
221
229
  trust_level: trust_level,
222
- plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: Digest::SHA256.hexdigest(File.binread(parts.plugin)) },
223
- requires_chi: record&.requires_chi,
224
- needs: record&.needs,
225
- scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
230
+ plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: FileDigest.sha256(parts.plugin) },
231
+ requires_chi: manifest&.requires_chi,
232
+ needs: manifest&.needs,
233
+ scripts: parts.scripts.transform_values { |src| FileDigest.sha256(src) },
226
234
  context_providers: parts.context_providers,
227
235
  block_renderers: parts.block_renderers,
228
- file_descriptions: file_descriptions(record, files_map),
229
- session_settings: record&.session_settings || [],
230
- session_setting_types: record&.session_setting_types || {},
231
- settings: record&.settings,
232
- setting_types: record&.setting_types || {}
236
+ file_descriptions: manifest ? manifest.file_descriptions.slice(*files_map.keys) : {},
237
+ session_settings: manifest&.session_settings || [],
238
+ session_setting_types: manifest&.session_setting_types || {},
239
+ settings: manifest&.settings,
240
+ setting_types: manifest&.setting_types || {}
233
241
  )
242
+ carry_unknown_keys(staging, parts.record, manifest) if manifest&.origin == :kept && !manifest.unknown_keys.empty?
243
+ end
244
+
245
+ def carry_unknown_keys(staging, record, manifest)
246
+ path = File.join(staging, "manifest.yml")
247
+ kept = YAML.load_file(record.kept_manifest_path) || {}
248
+ File.write(path, YAML.dump(YAML.load_file(path).merge(kept.slice(*manifest.unknown_keys))))
249
+ manifest.unknown_keys.each do |key|
250
+ @warnings << "#{Manifest.unknown_key_line(key)}; the build carries it as installed"
251
+ end
234
252
  end
235
253
 
236
254
  # The staged bundle to +out+: a zip or tar archive, or a directory,
@@ -279,18 +297,6 @@ module Samagotchi
279
297
  .select { |_, path| File.file?(path) }
280
298
  end
281
299
 
282
- # The descriptions the installed bundle's record keeps for the files
283
- # being built (its manifest's files: mappings), so a rebuild writes
284
- # them back; {} without a record.
285
- def file_descriptions(record, files_map)
286
- return {} unless record
287
-
288
- record.files.each_with_object({}) do |(key, entry), acc|
289
- text = entry[:description].to_s
290
- acc[key] = text if files_map.key?(key) && !text.empty?
291
- end
292
- end
293
-
294
300
  # Without an allowlist, a memory another installed bundle owns (its
295
301
  # record lists it) isn't the user's to share: left out, one line
296
302
  # each. The bundle being built (same name) keeps its own.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require_relative "file_digest"
4
4
 
5
5
  module Samagotchi
6
6
  module MemoryBundle
@@ -38,8 +38,9 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # A hook known only by its file (no declared event): its sha, the defaults.
41
+ # Callers pass a file that is there (FileDigest is nil for one that isn't).
41
42
  def self.of_file(path)
42
- new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
43
+ new(sha256: FileDigest.sha256(path))
43
44
  end
44
45
 
45
46
  # "sha256:<hex>" from either form, "" for none.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # The one digest of a file on disk, so the sha256 written by a record and
8
+ # the one read back at load time come from the same code.
9
+ #
10
+ # Only files are digested here. Bytes already in memory (a merged file's
11
+ # text, a memory's content that is also its base) keep their own
12
+ # Digest::SHA256.hexdigest call at the place that has them.
13
+ module FileDigest
14
+ # The file's SHA256 hex, read as bytes and streamed. nil when +path+
15
+ # isn't a file (missing, or a directory); an unreadable file raises
16
+ # SystemCallError, which callers that must keep going rescue.
17
+ def self.hex(path)
18
+ return nil unless File.file?(path)
19
+
20
+ Digest::SHA256.file(path).hexdigest
21
+ end
22
+
23
+ # The form a manifest or a record writes for a hook, rule, plugin or
24
+ # script: "sha256:<hex>", nil for a file that isn't there.
25
+ def self.sha256(path)
26
+ hex = hex(path)
27
+ hex && "sha256:#{hex}"
28
+ end
29
+ end
30
+ end
31
+ end