samagotchi 0.150.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/docs/architecture.md +2 -2
- data/docs/bundle-authoring.md +5 -5
- data/docs/cli.md +32 -7
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +4 -0
- data/docs/internals/block-renderers.md +98 -0
- data/docs/internals/bridge.md +53 -17
- data/docs/internals/bundles.md +49 -14
- data/docs/internals/objectives.md +6 -4
- data/docs/internals/web-frontend.md +2 -2
- data/docs/memory.md +7 -2
- data/docs/plugins.md +91 -14
- data/docs/releasing.md +7 -2
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/block_renderers.rb +116 -0
- data/lib/samagotchi/bridge/card_places.rb +93 -0
- data/lib/samagotchi/bridge/card_store.rb +219 -122
- data/lib/samagotchi/bridge.rb +12 -4
- data/lib/samagotchi/bundle_command/check.rb +43 -3
- data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
- data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +88 -10
- data/lib/samagotchi/bundle_needs.rb +4 -6
- data/lib/samagotchi/bundle_settings_check.rb +14 -13
- data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
- data/lib/samagotchi/child_env.rb +15 -0
- data/lib/samagotchi/context_providers.rb +42 -10
- data/lib/samagotchi/engine.rb +47 -6
- data/lib/samagotchi/extension_load.rb +4 -3
- data/lib/samagotchi/guardrail_wiring.rb +28 -19
- data/lib/samagotchi/guardrails/approval.rb +2 -2
- data/lib/samagotchi/guardrails/rules.rb +1 -0
- data/lib/samagotchi/guardrails/verdict.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
- data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
- data/lib/samagotchi/memory_bundle/builder.rb +39 -31
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
- data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
- data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
- data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
- data/lib/samagotchi/memory_bundle/installer.rb +91 -16
- data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
- data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
- data/lib/samagotchi/memory_bundle/merger.rb +7 -8
- data/lib/samagotchi/memory_bundle/profile.rb +3 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
- data/lib/samagotchi/memory_bundle/shas.rb +4 -3
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
- data/lib/samagotchi/memory_bundle/status.rb +11 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_config.rb +5 -4
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/system_one/record.rb +10 -2
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/tools/delegate.rb +20 -2
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -3
- data/lib/samagotchi/update_command.rb +13 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -13
- data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
- data/lib/samagotchi/web/markdown_renderer.rb +39 -6
- data/lib/samagotchi/web/message_display.rb +3 -3
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/block_frame.js +202 -0
- data/lib/samagotchi/web/public/block_host.js +502 -0
- data/lib/samagotchi/web/public/block_renderers.css +20 -0
- data/lib/samagotchi/web/public/block_source.js +37 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -5
- data/lib/samagotchi/web/public/card.js +17 -10
- data/lib/samagotchi/web/public/cards_place.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/history_reveal.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +2 -3
- data/lib/samagotchi/web/public/text_roots.js +6 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +6 -0
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +13 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -961,7 +961,7 @@ module Samagotchi
|
|
|
961
961
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
962
962
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
963
963
|
plugin_init_started plugin_init_finished working_set_updated participants_changed
|
|
964
|
-
verdict_recorded objectives_updated
|
|
964
|
+
verdict_recorded objectives_updated].freeze
|
|
965
965
|
|
|
966
966
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
967
967
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -1037,8 +1037,7 @@ module Samagotchi
|
|
|
1037
1037
|
end
|
|
1038
1038
|
|
|
1039
1039
|
def show_objective_proposals
|
|
1040
|
-
|
|
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.
|
|
@@ -1107,6 +1122,8 @@ module Samagotchi
|
|
|
1107
1122
|
# @raise [ArgumentError] a card without a title, or a bad action
|
|
1108
1123
|
def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
|
|
1109
1124
|
card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
|
|
1125
|
+
command = Thread.current[running_command_key]
|
|
1126
|
+
card[:command] = command if command
|
|
1110
1127
|
@event_routes.route(card, **EventRoutes::CARD_MARKS)
|
|
1111
1128
|
card[:id]
|
|
1112
1129
|
end
|
|
@@ -1126,6 +1143,20 @@ module Samagotchi
|
|
|
1126
1143
|
Thread.current[key] = outer
|
|
1127
1144
|
end
|
|
1128
1145
|
|
|
1146
|
+
# Run a session command's dispatch: a card shown on this thread says
|
|
1147
|
+
# it's that command's (:card's command:), for a reload that no longer has
|
|
1148
|
+
# the command's line above it (Bridge::CardStore). A thread of its own
|
|
1149
|
+
# isn't the command, nor a card shown after it.
|
|
1150
|
+
# @param name [String] the command, "/short"
|
|
1151
|
+
# @return the block's value
|
|
1152
|
+
def running_command(name)
|
|
1153
|
+
outer = Thread.current[running_command_key]
|
|
1154
|
+
Thread.current[running_command_key] = name
|
|
1155
|
+
yield
|
|
1156
|
+
ensure
|
|
1157
|
+
Thread.current[running_command_key] = outer
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1129
1160
|
# Whether an anytime command (/btw) still runs on its thread.
|
|
1130
1161
|
def anytime_running?
|
|
1131
1162
|
@lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
|
|
@@ -1732,6 +1763,7 @@ module Samagotchi
|
|
|
1732
1763
|
# no Revise on its answer, verdict or not, as a reload does).
|
|
1733
1764
|
def end_turn(turn, outcome, save: false, exhausted: false)
|
|
1734
1765
|
seconds = turn.elapsed
|
|
1766
|
+
turn.outcome = outcome
|
|
1735
1767
|
synchronize_events do
|
|
1736
1768
|
kept, event = yield(seconds)
|
|
1737
1769
|
event[:turn_id] = turn.id
|
|
@@ -2266,7 +2298,7 @@ module Samagotchi
|
|
|
2266
2298
|
|
|
2267
2299
|
messages = @session&.messages || []
|
|
2268
2300
|
covered = state[:covered].to_i
|
|
2269
|
-
turns_since = Array(messages).drop(covered).count { |m| Steer.
|
|
2301
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
|
|
2270
2302
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
2271
2303
|
end
|
|
2272
2304
|
|
|
@@ -2306,9 +2338,13 @@ module Samagotchi
|
|
|
2306
2338
|
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2307
2339
|
# +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
|
|
2308
2340
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2309
|
-
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
|
|
2341
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
|
|
2310
2342
|
def resumed? = resumed ? true : false
|
|
2311
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
|
+
|
|
2312
2348
|
# The turn's boundary events carry the origin only when it names a
|
|
2313
2349
|
# sender (not TurnOrigin::NONE), so payloads stay unchanged for callers
|
|
2314
2350
|
# that don't pass one; as its wire Hash (TurnOrigin#to_h), which the
|
|
@@ -2432,7 +2468,10 @@ module Samagotchi
|
|
|
2432
2468
|
ensure
|
|
2433
2469
|
release_turn(probe_cancel_before)
|
|
2434
2470
|
show_context_cards_after_turn
|
|
2435
|
-
|
|
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)
|
|
2436
2475
|
end
|
|
2437
2476
|
end
|
|
2438
2477
|
|
|
@@ -2974,6 +3013,8 @@ module Samagotchi
|
|
|
2974
3013
|
|
|
2975
3014
|
private
|
|
2976
3015
|
|
|
3016
|
+
def running_command_key = :"samagotchi_command_#{object_id}"
|
|
3017
|
+
|
|
2977
3018
|
# The one named seam for replacing a session's conversation (the
|
|
2978
3019
|
# array is replaced, never mutated in place). No lock: a reader gets
|
|
2979
3020
|
# the old array or the new one, and both are whole.
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
538
|
-
requires_chi:
|
|
539
|
-
@notify.call(kept_notice(bundle_name,
|
|
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,
|
|
551
|
+
def kept_notice(bundle_name, manifest)
|
|
544
552
|
id = @session_lookup.call&.id || "ID"
|
|
545
|
-
"bundle #{bundle_name} #{
|
|
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
|
|
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
|
|
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
|
|
61
|
-
# this chi doesn't meet
|
|
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: {},
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
|
@@ -269,7 +272,7 @@ module Samagotchi
|
|
|
269
272
|
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
270
273
|
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
271
274
|
|
|
272
|
-
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
|
|
275
|
+
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
|
|
273
276
|
end
|
|
274
277
|
return {} if @dry_run
|
|
275
278
|
|
|
@@ -291,7 +294,7 @@ module Samagotchi
|
|
|
291
294
|
else { status: "installed" }
|
|
292
295
|
end
|
|
293
296
|
FileUtils.chmod(0o755, dest) unless @dev
|
|
294
|
-
actual =
|
|
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]}..."
|
|
@@ -300,6 +303,12 @@ module Samagotchi
|
|
|
300
303
|
end
|
|
301
304
|
end
|
|
302
305
|
|
|
306
|
+
# A file only a block renderer's load names: the page loads it, nothing runs it.
|
|
307
|
+
def page_only?(file)
|
|
308
|
+
@manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
|
|
309
|
+
@manifest.context_providers.none? { |provider| provider.cmd.include?(file) }
|
|
310
|
+
end
|
|
311
|
+
|
|
303
312
|
# Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
|
|
304
313
|
# existing link goes first: a copy over it would write into the source.
|
|
305
314
|
def place(src, dest)
|
|
@@ -335,7 +344,7 @@ module Samagotchi
|
|
|
335
344
|
def warn_hooks_requires_chi
|
|
336
345
|
return if @manifest.nil? || @bundle_hooks.empty?
|
|
337
346
|
|
|
338
|
-
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi,
|
|
347
|
+
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
|
|
339
348
|
@warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
|
|
340
349
|
end
|
|
341
350
|
end
|
|
@@ -347,7 +356,7 @@ module Samagotchi
|
|
|
347
356
|
def warn_rules_requires_chi(rule_files)
|
|
348
357
|
return if @manifest.nil? || rule_files.empty?
|
|
349
358
|
|
|
350
|
-
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi,
|
|
359
|
+
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
|
|
351
360
|
@warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
|
|
352
361
|
"(chi update), guarded tool calls are refused"
|
|
353
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
|
|
|
@@ -30,8 +30,8 @@ module Samagotchi
|
|
|
30
30
|
# hooks basename => BundleHook; hook_files basename => path
|
|
31
31
|
# rules guardrails/*.yml paths
|
|
32
32
|
# plugin the installed plugin file, nil without one
|
|
33
|
-
# scripts file name => path; context_providers the parsed
|
|
34
|
-
InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers)
|
|
33
|
+
# scripts file name => path; context_providers, block_renderers the parsed lists
|
|
34
|
+
InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers, :block_renderers)
|
|
35
35
|
|
|
36
36
|
attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
|
|
37
37
|
|
|
@@ -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
|
|
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,12 +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:
|
|
188
|
+
context_providers: manifest ? manifest.context_providers : [],
|
|
189
|
+
block_renderers: manifest ? manifest.block_renderers : []
|
|
183
190
|
)
|
|
184
191
|
end
|
|
185
192
|
|
|
@@ -203,32 +210,45 @@ module Samagotchi
|
|
|
203
210
|
end
|
|
204
211
|
|
|
205
212
|
# manifest.yml for the staged bundle: requires_chi, needs, session_settings and settings (typed) and the
|
|
206
|
-
# file descriptions come from the installed bundle's
|
|
207
|
-
# trust_level from the CLI else
|
|
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.
|
|
208
216
|
def write_manifest(staging, name, version, files_map, parts)
|
|
209
|
-
|
|
217
|
+
manifest = parts.record&.manifest
|
|
210
218
|
trust_level = @trust_level
|
|
211
|
-
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
|
|
212
221
|
Manifest.write(
|
|
213
222
|
dir: staging,
|
|
214
223
|
name: name,
|
|
215
224
|
version: version,
|
|
216
225
|
scope: @scope,
|
|
217
|
-
description:
|
|
226
|
+
description: description,
|
|
218
227
|
files: files_map,
|
|
219
228
|
hooks: parts.hooks.empty? ? nil : parts.hooks,
|
|
220
229
|
trust_level: trust_level,
|
|
221
|
-
plugin: parts.plugin && { file: File.basename(parts.plugin), sha256:
|
|
222
|
-
requires_chi:
|
|
223
|
-
needs:
|
|
224
|
-
scripts: parts.scripts.transform_values { |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) },
|
|
225
234
|
context_providers: parts.context_providers,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
block_renderers: parts.block_renderers,
|
|
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 || {}
|
|
231
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
|
|
232
252
|
end
|
|
233
253
|
|
|
234
254
|
# The staged bundle to +out+: a zip or tar archive, or a directory,
|
|
@@ -277,18 +297,6 @@ module Samagotchi
|
|
|
277
297
|
.select { |_, path| File.file?(path) }
|
|
278
298
|
end
|
|
279
299
|
|
|
280
|
-
# The descriptions the installed bundle's record keeps for the files
|
|
281
|
-
# being built (its manifest's files: mappings), so a rebuild writes
|
|
282
|
-
# them back; {} without a record.
|
|
283
|
-
def file_descriptions(record, files_map)
|
|
284
|
-
return {} unless record
|
|
285
|
-
|
|
286
|
-
record.files.each_with_object({}) do |(key, entry), acc|
|
|
287
|
-
text = entry[:description].to_s
|
|
288
|
-
acc[key] = text if files_map.key?(key) && !text.empty?
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
|
|
292
300
|
# Without an allowlist, a memory another installed bundle owns (its
|
|
293
301
|
# record lists it) isn't the user's to share: left out, one line
|
|
294
302
|
# each. The bundle being built (same name) keeps its own.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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:
|
|
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest"
|
|
4
3
|
require "time"
|
|
4
|
+
require_relative "file_digest"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
7
|
module MemoryBundle
|
|
@@ -156,7 +156,7 @@ module Samagotchi
|
|
|
156
156
|
# read isn't known to be level. No digest (an older mark): it isn't known
|
|
157
157
|
# either, and a retry would have settled it.
|
|
158
158
|
def differs?(path, incoming_sha)
|
|
159
|
-
incoming_sha.nil? ||
|
|
159
|
+
incoming_sha.nil? || FileDigest.hex(path) != incoming_sha
|
|
160
160
|
rescue SystemCallError
|
|
161
161
|
true
|
|
162
162
|
end
|