samagotchi 0.158.0 → 0.160.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 +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +12 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "time"
|
|
@@ -13,7 +14,9 @@ module Samagotchi
|
|
|
13
14
|
# written aside and renamed under an exclusive flock (several chi
|
|
14
15
|
# processes share it). An entry relaxes an ask, never a deny:
|
|
15
16
|
# session — this exact call (tool + normalized command / paths) in
|
|
16
|
-
# this session;
|
|
17
|
+
# this session; a command's in its place too (the
|
|
18
|
+
# worktree or folder it ran in, and a digest of its env:),
|
|
19
|
+
# since the same text acts on whatever they point at;
|
|
17
20
|
# repo — this exact call in this repo (the cwd outside a repo);
|
|
18
21
|
# rule — anything this rule (from this source) asks about here.
|
|
19
22
|
# "This repo" is the repository (repo: its common git dir), so an
|
|
@@ -74,11 +77,14 @@ module Samagotchi
|
|
|
74
77
|
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
75
78
|
session_id = verdict.context&.session_id
|
|
76
79
|
session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
|
|
80
|
+
command_place = session_place(verdict)
|
|
77
81
|
list.find do |e|
|
|
78
82
|
next false if session_only && e["scope"] != "session"
|
|
79
83
|
|
|
80
84
|
case e["scope"]
|
|
81
|
-
when "session"
|
|
85
|
+
when "session"
|
|
86
|
+
session_id && e["session_id"] == session_id && e["key"] == key &&
|
|
87
|
+
(command_place.nil? || e.slice("repo_root", "env") == command_place)
|
|
82
88
|
when "repo" then here.call(e) && e["key"] == key
|
|
83
89
|
when "rule"
|
|
84
90
|
# One rule's grant can't answer for a second rule's ask on the call.
|
|
@@ -94,7 +100,9 @@ module Samagotchi
|
|
|
94
100
|
entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
|
|
95
101
|
"acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
96
102
|
case scope
|
|
97
|
-
when "session"
|
|
103
|
+
when "session"
|
|
104
|
+
entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
105
|
+
entry.merge!(session_place(verdict) || {})
|
|
98
106
|
when "repo"
|
|
99
107
|
entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
|
|
100
108
|
when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
|
|
@@ -131,6 +139,22 @@ module Samagotchi
|
|
|
131
139
|
t&.repo_root || t&.cwd || verdict.context&.cwd
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# Where a session entry of a command holds: the call's worktree root
|
|
143
|
+
# (or folder: #place_for; never the common git dir, which every
|
|
144
|
+
# worktree shares) and, when it sets one, a sha256 of its env: (never
|
|
145
|
+
# the env: it may hold a secret). nil for a call with no command:
|
|
146
|
+
# its paths are its place. An entry without them (stored before) no
|
|
147
|
+
# longer matches: one more ask.
|
|
148
|
+
# @return [Hash, nil]
|
|
149
|
+
def self.session_place(verdict)
|
|
150
|
+
t = verdict.targets
|
|
151
|
+
return nil unless t&.command
|
|
152
|
+
|
|
153
|
+
env = t.env.to_h
|
|
154
|
+
digest = env.empty? ? nil : Digest::SHA256.hexdigest(JSON.generate(env.map { |k, v| [k.to_s, v.to_s] }.sort))
|
|
155
|
+
{ "repo_root" => place_for(verdict), "env" => digest }.compact
|
|
156
|
+
end
|
|
157
|
+
|
|
134
158
|
private
|
|
135
159
|
|
|
136
160
|
# @param locked [Boolean] the caller holds the store's lock
|
|
@@ -52,7 +52,7 @@ module Samagotchi
|
|
|
52
52
|
RULE = "pair-boundary"
|
|
53
53
|
SOURCE = "core"
|
|
54
54
|
OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
|
|
55
|
-
find_tools objectives].freeze
|
|
55
|
+
find_tools objectives schedule_task list_schedules cancel_schedule].freeze
|
|
56
56
|
OWNER_VOICED_PREFIX = "delegate_"
|
|
57
57
|
# The background task tools that read the owner's tasks (a pair turn
|
|
58
58
|
# starts none: task_create is owner-voiced, and its execute is never
|
|
@@ -24,7 +24,6 @@ module Samagotchi
|
|
|
24
24
|
# (a chi.on handler :log, or :deny for an on_error: :fail_closed one).
|
|
25
25
|
HOOK_FAILURE_EFFECTS = {
|
|
26
26
|
log: "it's skipped, and only the log has its next failures",
|
|
27
|
-
skip: "it's skipped, and its next failures are silent",
|
|
28
27
|
deny: "it denies every tool call it guards, and its next failures are in each denial's reason"
|
|
29
28
|
}.freeze
|
|
30
29
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "asset_state"
|
|
5
|
+
require_relative "file_actions"
|
|
4
6
|
require_relative "file_digest"
|
|
5
7
|
require_relative "manifest"
|
|
6
8
|
require_relative "provenance"
|
|
@@ -20,8 +22,11 @@ module Samagotchi
|
|
|
20
22
|
# warnings, and #run returns what the record (Provenance#write) keeps.
|
|
21
23
|
# A dev install (+dev+, `chi bundle install --dev`) links each file to
|
|
22
24
|
# the source instead of copying it, and warns of no checksum mismatch.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
+
# Each folder is one loop over its files' AssetStates (#install_set):
|
|
26
|
+
# FileActions' asset tables say what each gets, the dry run reading the
|
|
27
|
+
# same row. An edited file it replaces or removes is copied to the trash
|
|
28
|
+
# first, under its subfolder (AssetState#edited?, Trash.edit?'s rule); a
|
|
29
|
+
# link never is.
|
|
25
30
|
class AssetInstaller
|
|
26
31
|
# What was installed: the paths Provenance#write records (each
|
|
27
32
|
# empty / nil on a dry run).
|
|
@@ -32,6 +37,10 @@ module Samagotchi
|
|
|
32
37
|
# The retire's summary words (Installer#summary): why the hooks went.
|
|
33
38
|
RETIRED = "chi no longer loads class-file bundle hooks"
|
|
34
39
|
|
|
40
|
+
# A dry run's word for a real run's (Installer#summary): nothing would
|
|
41
|
+
# be written to a file already up to date.
|
|
42
|
+
DRY_WORDS = { installed: :would_install, updated: :would_update, up_to_date: :up_to_date }.freeze
|
|
43
|
+
|
|
35
44
|
# @param source_dir [String] the normalized bundle source
|
|
36
45
|
# @param manifest [Manifest, nil] nil for a non-strict install without one
|
|
37
46
|
# @param existing [InstalledBundle, nil] the record before this install
|
|
@@ -65,9 +74,9 @@ module Samagotchi
|
|
|
65
74
|
def run
|
|
66
75
|
incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
|
|
67
76
|
.reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
|
|
68
|
-
guardrail_files =
|
|
69
|
-
plugin_file =
|
|
70
|
-
script_files =
|
|
77
|
+
guardrail_files = install_set(:rule, rules_incoming(incoming_rules))
|
|
78
|
+
plugin_file = install_set(:plugin, plugin_incoming).values.first
|
|
79
|
+
script_files = install_set(:script, scripts_incoming)
|
|
71
80
|
# After the plugin and scripts, which raise for a file the manifest
|
|
72
81
|
# names and the bundle lacks: a refused install leaves hooks/ as it was.
|
|
73
82
|
retire_hooks
|
|
@@ -95,8 +104,8 @@ module Samagotchi
|
|
|
95
104
|
# load): every file in it goes, an edited one (or one the record
|
|
96
105
|
# doesn't know) copied to the trash first, a dev link never (it points
|
|
97
106
|
# at the source, which stays). Each is a :removed result (a dry run's
|
|
98
|
-
# :would_remove
|
|
99
|
-
#
|
|
107
|
+
# :would_remove) with the RETIRED note; an empty hooks/ dir goes
|
|
108
|
+
# without a word. The record keeps no hooks
|
|
100
109
|
# (Provenance#write).
|
|
101
110
|
def retire_hooks
|
|
102
111
|
dir = @provenance.hooks_dir
|
|
@@ -104,9 +113,13 @@ module Samagotchi
|
|
|
104
113
|
|
|
105
114
|
files = File.symlink?(dir) ? [] : Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.reject { |f| File.directory?(File.join(dir, f)) }
|
|
106
115
|
files.each do |file|
|
|
107
|
-
@results["hooks/#{file}"] = @dry_run ?
|
|
116
|
+
@results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
|
|
117
|
+
end
|
|
118
|
+
if @dry_run
|
|
119
|
+
edits = Trash.edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
|
|
120
|
+
@warnings << "Would copy to the trash first: #{edits.join(", ")}" unless edits.empty?
|
|
121
|
+
return
|
|
108
122
|
end
|
|
109
|
-
return if @dry_run
|
|
110
123
|
|
|
111
124
|
@trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
|
|
112
125
|
FileUtils.rm_rf(dir)
|
|
@@ -114,44 +127,15 @@ module Samagotchi
|
|
|
114
127
|
|
|
115
128
|
def retired = Installer::FileResult.new(word: :removed, note: RETIRED)
|
|
116
129
|
|
|
117
|
-
# The bundle's guardrails/*.yml
|
|
118
|
-
|
|
119
|
-
# installed are reported up to date, not "Installed".
|
|
120
|
-
# @return [Hash{String => String}] basename => installed path ({} on a dry run)
|
|
121
|
-
def install_guardrails(incoming_rules)
|
|
122
|
-
target = @provenance.guardrails_dir
|
|
123
|
-
results = incoming_rules.to_h { |src| [src, result_for(src, File.join(target, File.basename(src)), updated: false)] }
|
|
124
|
-
if @dry_run
|
|
125
|
-
results.each { |src, result| @results["guardrails/#{File.basename(src)}"] = planned(result) }
|
|
126
|
-
return {}
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
@trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
|
|
130
|
-
FileUtils.rm_rf(target)
|
|
131
|
-
return {} if incoming_rules.empty?
|
|
130
|
+
# The bundle's guardrails/*.yml, by basename.
|
|
131
|
+
def rules_incoming(incoming_rules) = incoming_rules.to_h { |src| [File.basename(src), src] }
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
@results["guardrails/#{File.basename(src)}"] = results[src]
|
|
138
|
-
[File.basename(src), dest]
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
143
|
-
# warning when its sha256 differs from the declared one or this chi
|
|
144
|
-
# doesn't meet requires_chi (the Engine then won't load it).
|
|
145
|
-
# @return [String, nil] the installed file (nil: none, or a dry run)
|
|
146
|
-
def install_plugin
|
|
133
|
+
# The manifest's plugin file, warning when this chi doesn't meet
|
|
134
|
+
# requires_chi (the Engine then won't load it). {} without one.
|
|
135
|
+
# @return [Hash{String => String}] its file => the source file
|
|
136
|
+
def plugin_incoming
|
|
147
137
|
plugin = @manifest&.plugin
|
|
148
|
-
unless plugin
|
|
149
|
-
unless @dry_run
|
|
150
|
-
@trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
|
|
151
|
-
FileUtils.rm_rf(@provenance.plugin_dir)
|
|
152
|
-
end
|
|
153
|
-
return nil
|
|
154
|
-
end
|
|
138
|
+
return {} unless plugin
|
|
155
139
|
|
|
156
140
|
src = File.join(@bundle_dir, plugin[:file])
|
|
157
141
|
raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
@@ -160,83 +144,101 @@ module Samagotchi
|
|
|
160
144
|
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
|
|
161
145
|
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
162
146
|
end
|
|
163
|
-
|
|
164
|
-
result = result_for(src, dest, updated: @upgrade && @provenance.installed?)
|
|
165
|
-
if @dry_run
|
|
166
|
-
@results[plugin[:file]] = planned(result)
|
|
167
|
-
return nil
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
@trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
|
|
171
|
-
FileUtils.rm_rf(@provenance.plugin_dir)
|
|
172
|
-
FileUtils.mkdir_p(@provenance.plugin_dir)
|
|
173
|
-
place(src, dest)
|
|
174
|
-
@results[plugin[:file]] = result
|
|
175
|
-
warning = FileDigest.mismatch("plugin #{plugin[:file]}", dest, @manifest.checksum_for_plugin) if @strict && !@dev
|
|
176
|
-
@warnings << warning if warning
|
|
177
|
-
dest
|
|
147
|
+
{ plugin[:file] => src }
|
|
178
148
|
end
|
|
179
149
|
|
|
180
|
-
# The manifest's scripts
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
# @return [Hash{String => String}] file name => installed path
|
|
184
|
-
def install_scripts
|
|
150
|
+
# The manifest's scripts (a dev install warns of one it can't run).
|
|
151
|
+
# @return [Hash{String => String}] file name => the source file
|
|
152
|
+
def scripts_incoming
|
|
185
153
|
scripts = @manifest&.scripts || {}
|
|
186
|
-
scripts.
|
|
154
|
+
scripts.keys.to_h do |file|
|
|
187
155
|
src = File.join(@bundle_dir, "scripts", file)
|
|
188
156
|
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
189
157
|
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
190
158
|
|
|
191
159
|
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
|
|
160
|
+
[file, src]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
|
|
165
|
+
# replaced whole by +incoming+: each file's AssetState and its action
|
|
166
|
+
# (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
|
|
167
|
+
# bundle), one result per incoming file (a dry run's DRY_WORDS of it,
|
|
168
|
+
# and nothing written), an edit copied to the trash first (save_first),
|
|
169
|
+
# then the folder replaced. A removed file is named in the summary.
|
|
170
|
+
# A plugin or script whose sha256 differs from the declared one warns
|
|
171
|
+
# (not a dev link: it is the source).
|
|
172
|
+
# @param incoming [Hash{String => String}] name => the source file, in
|
|
173
|
+
# the order the results list them
|
|
174
|
+
# @return [Hash{String => String}] name => installed path ({} on a dry run)
|
|
175
|
+
def install_set(kind_of_file, incoming)
|
|
176
|
+
dir, recorded = folder(kind_of_file)
|
|
177
|
+
command = @upgrade && @provenance.installed? ? :upgrade : :install
|
|
178
|
+
states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
|
|
179
|
+
.to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
|
|
180
|
+
if @dry_run
|
|
181
|
+
copies = states.filter_map { |_, (asset, action)| asset.label if action&.save_first }
|
|
182
|
+
@warnings << "Would copy to the trash first: #{copies.join(", ")}" unless copies.empty?
|
|
183
|
+
end
|
|
184
|
+
results = incoming.keys.to_h do |name|
|
|
185
|
+
asset, action = states.fetch(name)
|
|
186
|
+
[name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
|
|
192
187
|
end
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
188
|
+
dropped = states.filter_map do |name, (asset, action)|
|
|
189
|
+
next unless action&.do == :remove
|
|
190
|
+
|
|
191
|
+
[result_key(kind_of_file, name),
|
|
192
|
+
Installer::FileResult.new(word: @dry_run ? :would_remove : :removed, state: asset, action: action,
|
|
193
|
+
note: "no longer in the bundle")]
|
|
196
194
|
end
|
|
197
195
|
if @dry_run
|
|
198
|
-
results.each { |
|
|
196
|
+
results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
|
|
197
|
+
dropped.each { |key, result| @results[key] = result }
|
|
199
198
|
return {}
|
|
200
199
|
end
|
|
201
200
|
|
|
202
|
-
@trash.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
FileUtils.mkdir_p(@provenance.scripts_dir)
|
|
208
|
-
scripts.to_h do |file, declared|
|
|
209
|
-
dest = File.join(@provenance.scripts_dir, file)
|
|
210
|
-
place(File.join(@bundle_dir, "scripts", file), dest)
|
|
211
|
-
@results["scripts/#{file}"] = results[file]
|
|
212
|
-
FileUtils.chmod(0o755, dest) unless @dev
|
|
213
|
-
warning = FileDigest.mismatch("script #{file}", dest, declared.to_s.delete_prefix("sha256:")) if @strict && !@dev
|
|
214
|
-
@warnings << warning if warning
|
|
215
|
-
[file, dest]
|
|
201
|
+
states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
|
|
202
|
+
FileUtils.rm_rf(dir)
|
|
203
|
+
if incoming.empty?
|
|
204
|
+
dropped.each { |key, result| @results[key] = result }
|
|
205
|
+
return {}
|
|
216
206
|
end
|
|
207
|
+
|
|
208
|
+
FileUtils.mkdir_p(dir)
|
|
209
|
+
installed = incoming.to_h do |name, src|
|
|
210
|
+
dest = File.join(dir, name)
|
|
211
|
+
place(src, dest)
|
|
212
|
+
@results[result_key(kind_of_file, name)] = results[name]
|
|
213
|
+
FileUtils.chmod(0o755, dest) if kind_of_file == :script && !@dev
|
|
214
|
+
if (declared = declared_sha(kind_of_file, name)) && results[name].action.do == :write_incoming && @strict && !@dev
|
|
215
|
+
warning = FileDigest.mismatch("#{kind_of_file} #{name}", dest, declared)
|
|
216
|
+
@warnings << warning if warning
|
|
217
|
+
end
|
|
218
|
+
[name, dest]
|
|
219
|
+
end
|
|
220
|
+
dropped.each { |key, result| @results[key] = result }
|
|
221
|
+
installed
|
|
217
222
|
end
|
|
218
223
|
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
else :installed
|
|
227
|
-
end
|
|
228
|
-
Installer::FileResult.new(word: word)
|
|
224
|
+
# The kind's folder and the record's shas for it.
|
|
225
|
+
def folder(kind_of_file)
|
|
226
|
+
case kind_of_file
|
|
227
|
+
when :rule then [@provenance.guardrails_dir, @existing&.guardrails]
|
|
228
|
+
when :plugin then [@provenance.plugin_dir, @existing&.plugin_shas]
|
|
229
|
+
else [@provenance.scripts_dir, @existing&.scripts]
|
|
230
|
+
end
|
|
229
231
|
end
|
|
230
232
|
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
when :
|
|
239
|
-
|
|
233
|
+
# Its key in the Installer's results (the summary's name): a rule's
|
|
234
|
+
# guardrails/r.yml, the plugin's own file name, a script's scripts/s.sh.
|
|
235
|
+
def result_key(kind_of_file, name) = kind_of_file == :plugin ? name : "#{AssetState::FOLDERS.fetch(kind_of_file)}/#{name}"
|
|
236
|
+
|
|
237
|
+
# The manifest's sha256 for a plugin or script (hex), nil for a rule.
|
|
238
|
+
def declared_sha(kind_of_file, name)
|
|
239
|
+
case kind_of_file
|
|
240
|
+
when :plugin then @manifest.checksum_for_plugin
|
|
241
|
+
when :script then @manifest.scripts[name].to_s.delete_prefix("sha256:")
|
|
240
242
|
end
|
|
241
243
|
end
|
|
242
244
|
|
|
@@ -267,7 +269,7 @@ module Samagotchi
|
|
|
267
269
|
# GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
|
|
268
270
|
# requires_chi this chi doesn't meet, and a session that never loaded
|
|
269
271
|
# them records a required load failure (the gate refuses guarded calls
|
|
270
|
-
# until chi is updated): say so at install, as
|
|
272
|
+
# until chi is updated): say so at install, as #plugin_incoming does.
|
|
271
273
|
def warn_rules_requires_chi(rule_files)
|
|
272
274
|
return if @manifest.nil? || rule_files.empty?
|
|
273
275
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "trash"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module MemoryBundle
|
|
8
|
+
# One file of a bundle's own folder (a guardrail rule, the plugin, a
|
|
9
|
+
# script) as a command finds it: derived from the file on disk, the sha
|
|
10
|
+
# the record holds and the source file that would take its place, never
|
|
11
|
+
# stored. FileActions' ASSET_* tables say what each command does in each
|
|
12
|
+
# state; AssetInstaller, ShippedUpdate's plan, Uninstaller and Status read
|
|
13
|
+
# it. Whether it is edited is Trash.edit?'s answer (what the trash keeps a
|
|
14
|
+
# copy of), asked once in .of.
|
|
15
|
+
#
|
|
16
|
+
# name its path in its folder (r.yml, plugin.rb, s.sh)
|
|
17
|
+
# path where it is installed
|
|
18
|
+
# kind_of_file :rule, :plugin or :script
|
|
19
|
+
# state :new not there (or a broken link), the source has it
|
|
20
|
+
# :unchanged the source's bytes already (a dev link to them too)
|
|
21
|
+
# :pristine the recorded sha, the source has other bytes
|
|
22
|
+
# :edited neither the recorded sha (none recorded: unknown,
|
|
23
|
+
# so edited) nor the source's bytes
|
|
24
|
+
# :linked a link (a dev install's) to other bytes than the
|
|
25
|
+
# source's: it points at a source nothing here touches
|
|
26
|
+
# :dropped_pristine, :dropped_edited there, the source hasn't
|
|
27
|
+
# it (with no source read, as uninstall and status
|
|
28
|
+
# do, every file there is one of these)
|
|
29
|
+
# :missing recorded, not there, the source hasn't it
|
|
30
|
+
# recorded the record's sha256 for it (nil: not in the record)
|
|
31
|
+
# incoming the source file that replaces it (nil: none)
|
|
32
|
+
AssetState = Data.define(:name, :path, :kind_of_file, :state, :recorded, :incoming) do
|
|
33
|
+
# @param path [String] the installed file
|
|
34
|
+
# @param recorded [String, nil] its recorded sha256
|
|
35
|
+
# @param incoming [String, nil] the source file put in its place
|
|
36
|
+
def self.of(path, kind_of_file:, recorded: nil, incoming: nil, name: File.basename(path))
|
|
37
|
+
new(name: name, path: path, kind_of_file: kind_of_file, state: state_of(path, recorded, incoming),
|
|
38
|
+
recorded: recorded, incoming: incoming)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every file of the folder +dir+ a command meets: each file in it (at
|
|
42
|
+
# any depth, as Trash#copy_edits reads it; a linked dir isn't followed),
|
|
43
|
+
# each incoming one and each recorded one, by name.
|
|
44
|
+
# @param recorded [Hash, nil] name => recorded sha256
|
|
45
|
+
# @param incoming [Hash] name => the source file
|
|
46
|
+
# @return [Array<AssetState>]
|
|
47
|
+
def self.in(dir, kind_of_file:, recorded: nil, incoming: {})
|
|
48
|
+
recorded ||= {}
|
|
49
|
+
on_disk = if File.symlink?(dir) || !File.directory?(dir) then []
|
|
50
|
+
else Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).reject { |file| File.directory?(File.join(dir, file)) }
|
|
51
|
+
end
|
|
52
|
+
(on_disk | incoming.keys | recorded.keys).sort.map do |name|
|
|
53
|
+
of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], incoming: incoming[name], name: name)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.state_of(path, recorded, incoming)
|
|
58
|
+
if incoming && File.file?(path) && FileUtils.identical?(incoming, path) then :unchanged
|
|
59
|
+
elsif File.symlink?(path) && File.exist?(path) then :linked
|
|
60
|
+
elsif !File.file?(path) then incoming ? :new : :missing
|
|
61
|
+
elsif Trash.edit?(path, recorded: recorded, incoming: incoming) then incoming ? :edited : :dropped_edited
|
|
62
|
+
else incoming ? :pristine : :dropped_pristine
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :state_of
|
|
66
|
+
|
|
67
|
+
# Trash.edit?'s rule (asked in .of): an edit a replace or a removal
|
|
68
|
+
# would lose, so the trash keeps a copy first.
|
|
69
|
+
def edited? = %i[edited dropped_edited].include?(state)
|
|
70
|
+
|
|
71
|
+
# Where the trash keeps its copy, and the plan names it: guardrails/r.yml,
|
|
72
|
+
# plugin/plugin.rb, scripts/s.sh.
|
|
73
|
+
def label = "#{self.class::FOLDERS.fetch(kind_of_file)}/#{name}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class AssetState
|
|
77
|
+
# Each kind's folder in the bundle's own dir (and in the trash).
|
|
78
|
+
FOLDERS = { rule: "guardrails", plugin: "plugin", script: "scripts" }.freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -2,38 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module MemoryBundle
|
|
5
|
-
# One class-file hook's
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# ("sha256:<hex>", "" when none is recorded), the events it registers
|
|
10
|
-
# for (manifest `event:` for one, `events:` a list; none: it isn't
|
|
11
|
-
# loaded), on_error (skip, log or fail_closed) and priority. The one
|
|
12
|
-
# place the defaults live.
|
|
13
|
-
class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
|
|
14
|
-
DEFAULT_ON_ERROR = "skip"
|
|
15
|
-
DEFAULT_PRIORITY = 100
|
|
5
|
+
# One class-file hook's sha256 in an older chi's installed record; its
|
|
6
|
+
# other fields are ignored now that class-file hooks are retired.
|
|
7
|
+
BundleHook = Data.define(:sha256) do
|
|
8
|
+
def initialize(sha256: "") = super
|
|
16
9
|
|
|
17
|
-
# @param
|
|
18
|
-
#
|
|
19
|
-
# `events:`); `event:` and `events:` together are joined, blanks
|
|
20
|
-
# and repeats dropped
|
|
21
|
-
def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
|
|
22
|
-
names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
23
|
-
super(sha256: sha256, events: names, on_error: on_error, priority: priority)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
|
|
27
|
-
# symbol keys (a read record); missing or blank values get the
|
|
28
|
-
# defaults
|
|
10
|
+
# @param raw [Hash, BundleHook, nil] string or symbol keys; legacy
|
|
11
|
+
# metadata beyond sha256 is ignored
|
|
29
12
|
def self.parse(raw)
|
|
30
13
|
return raw if raw.is_a?(BundleHook)
|
|
31
14
|
|
|
32
15
|
h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
|
|
33
|
-
|
|
34
|
-
new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
|
|
35
|
-
on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
|
|
36
|
-
priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
|
|
16
|
+
new(sha256: prefixed(h["sha256"]))
|
|
37
17
|
end
|
|
38
18
|
|
|
39
19
|
# "sha256:<hex>" from either form, "" for none.
|
|
@@ -22,6 +22,8 @@ module Samagotchi
|
|
|
22
22
|
# :keep left as it is (already the bundle's, or kept)
|
|
23
23
|
# :skip left as it is, the bundle doesn't take it
|
|
24
24
|
# :move_to_trash moved to the trash (a removal)
|
|
25
|
+
# :remove deleted (an asset: save_first copies an edit to the
|
|
26
|
+
# trash first)
|
|
25
27
|
# :refuse the command stops
|
|
26
28
|
# :restore the bundle's text put back (SystemBundle at its version)
|
|
27
29
|
# save_first: a copy of the old bytes goes to the trash before a write
|
|
@@ -157,6 +159,54 @@ module Samagotchi
|
|
|
157
159
|
|
|
158
160
|
TABLES = { install: INSTALL, force: FORCE, upgrade: UPGRADE, remerge: REMERGE, restore: RESTORE,
|
|
159
161
|
uninstall: UNINSTALL }.freeze
|
|
162
|
+
|
|
163
|
+
# A bundle's own folder (rules, the plugin, scripts: AssetState), which
|
|
164
|
+
# every install replaces whole: the source's files go in, the rest go,
|
|
165
|
+
# an edit (AssetState#edited?) copied to the trash first. A cell is keyed
|
|
166
|
+
# [state, kind_of_file] or [state, :any]. A file the source has is
|
|
167
|
+
# written even when it has its bytes already (and so checked against the
|
|
168
|
+
# manifest's sha, as any other).
|
|
169
|
+
ASSET_WRITE = Action.new(do: :write_incoming, word: :installed, next_base: :on_disk)
|
|
170
|
+
ASSET_REMOVE = Action.new(do: :remove, word: nil, next_base: :none, record: :drop)
|
|
171
|
+
ASSET_GONE = Action.new(do: :skip, word: nil, next_base: :none, record: :drop)
|
|
172
|
+
|
|
173
|
+
# A plain install, install --force, and an upgrade with no record yet.
|
|
174
|
+
ASSET_INSTALL = {
|
|
175
|
+
%i[new any] => ASSET_WRITE, %i[unchanged any] => ASSET_WRITE.with(word: :up_to_date),
|
|
176
|
+
%i[pristine any] => ASSET_WRITE, %i[edited any] => ASSET_WRITE.with(save_first: true),
|
|
177
|
+
%i[linked any] => ASSET_WRITE,
|
|
178
|
+
# The source dropped it: it goes with the folder, without a word.
|
|
179
|
+
%i[dropped_pristine any] => ASSET_REMOVE, %i[dropped_edited any] => ASSET_REMOVE.with(save_first: true),
|
|
180
|
+
%i[missing any] => ASSET_GONE
|
|
181
|
+
}.freeze
|
|
182
|
+
|
|
183
|
+
# An upgrade (--force too) over the record: a file it replaces is updated.
|
|
184
|
+
ASSET_UPGRADE = ASSET_INSTALL.merge(
|
|
185
|
+
%i[pristine any] => ASSET_WRITE.with(word: :updated),
|
|
186
|
+
%i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
|
|
187
|
+
%i[linked any] => ASSET_WRITE.with(word: :updated),
|
|
188
|
+
%i[pristine rule] => ASSET_WRITE.with(word: :updated),
|
|
189
|
+
%i[edited rule] => ASSET_WRITE.with(word: :updated, save_first: true),
|
|
190
|
+
%i[linked rule] => ASSET_WRITE
|
|
191
|
+
).freeze
|
|
192
|
+
|
|
193
|
+
# Uninstall: the folder goes (no source, so every file there is a
|
|
194
|
+
# dropped_* or a link), an edit copied to the trash first, --force or not.
|
|
195
|
+
ASSET_UNINSTALL = {
|
|
196
|
+
%i[dropped_pristine any] => ASSET_REMOVE, %i[dropped_edited any] => ASSET_REMOVE.with(save_first: true),
|
|
197
|
+
%i[linked any] => ASSET_REMOVE, %i[missing any] => ASSET_GONE
|
|
198
|
+
}.freeze
|
|
199
|
+
|
|
200
|
+
ASSET_TABLES = { install: ASSET_INSTALL, upgrade: ASSET_UPGRADE, uninstall: ASSET_UNINSTALL }.freeze
|
|
201
|
+
|
|
202
|
+
# The command's action for an AssetState: [state, kind_of_file], then
|
|
203
|
+
# [state, :any].
|
|
204
|
+
# @param command [Symbol] an ASSET_TABLES key
|
|
205
|
+
# @return [Action, nil] nil: the command never meets the state
|
|
206
|
+
def self.for_asset(command, asset)
|
|
207
|
+
table = ASSET_TABLES.fetch(command)
|
|
208
|
+
table[[asset.state, asset.kind_of_file]] || table[[asset.state, :any]]
|
|
209
|
+
end
|
|
160
210
|
# The kinds whose row depends on another bundle listing the file.
|
|
161
211
|
SHARED_ASKED = %i[dropped_pristine dropped_edited pristine pristine_no_base edited edited_no_base].freeze
|
|
162
212
|
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# legacy_base: true on an older mark that has since got its incoming_sha (.legacy_mark?);
|
|
27
27
|
# why, missed, missed_places: FileStanding::Behind's, nil on an older mark;
|
|
28
28
|
# merged_version, merged_at, merged_trash: a merge no report has shown (Provenance#note_merged)
|
|
29
|
-
# hooks basename => BundleHook
|
|
29
|
+
# hooks basename => BundleHook (sha256; other legacy fields ignored)
|
|
30
30
|
# guardrails basename => recorded sha256 ("" when none)
|
|
31
31
|
# scripts file name => recorded sha256 ("" when none)
|
|
32
32
|
# plugin PluginRef, nil without one
|
|
@@ -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
|
|
@@ -356,7 +356,7 @@ module Samagotchi
|
|
|
356
356
|
@bundle_dir = @source_dir = prepared.dir
|
|
357
357
|
@source_owned = prepared.owned
|
|
358
358
|
@source_commit = prepared.commit
|
|
359
|
-
|
|
359
|
+
Manifest::HooksRetired.refuse_source_files(@bundle_dir)
|
|
360
360
|
@manifest = Manifest.read(dir: @bundle_dir)
|
|
361
361
|
clash = @manifest.session_settings_clash
|
|
362
362
|
raise InstallError, clash if clash
|
|
@@ -371,15 +371,6 @@ module Samagotchi
|
|
|
371
371
|
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
372
372
|
end
|
|
373
373
|
|
|
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
374
|
# A bundle whose requires_chi this chi doesn't meet is not installed at
|
|
384
375
|
# all: --force is what installs it, and its plugin and rules then
|
|
385
376
|
# warn that they won't load (AssetInstaller). The words come from
|
|
@@ -557,6 +548,7 @@ module Samagotchi
|
|
|
557
548
|
# cell's text merge runs on temp copies). --force would install every
|
|
558
549
|
# file, unread.
|
|
559
550
|
def preview_memory(file_key, state, action)
|
|
551
|
+
preview_trash_copy(file_key, state, action)
|
|
560
552
|
return @results[file_key] = FileResult.new(word: :would_install, state: state, action: action) if @force
|
|
561
553
|
return refuse_unreadable(file_key, state.paths[:current]) if action.do == :refuse
|
|
562
554
|
|
|
@@ -564,9 +556,20 @@ module Samagotchi
|
|
|
564
556
|
action = decision.action if decision
|
|
565
557
|
return note_conflict(file_key, decision.outcome, state) if action.behind?
|
|
566
558
|
|
|
559
|
+
preview_trash_copy(file_key, state, action)
|
|
567
560
|
@results[file_key] = FileResult.new(word: DRY_WORDS.fetch(action.word), state: state, action: action)
|
|
568
561
|
end
|
|
569
562
|
|
|
563
|
+
def preview_trash_copy(file_key, state, action)
|
|
564
|
+
return unless action.save_first
|
|
565
|
+
|
|
566
|
+
current = state.paths[:current]
|
|
567
|
+
incoming = state.paths[:incoming]
|
|
568
|
+
return unless File.file?(current) && !FileUtils.identical?(incoming, current)
|
|
569
|
+
|
|
570
|
+
@warnings << "Would copy to the trash first: #{file_key}"
|
|
571
|
+
end
|
|
572
|
+
|
|
570
573
|
# Strict mode: a memory that takes the bundle's text (Action#takes_source?:
|
|
571
574
|
# a new or missing file, --force, an upgrade's or a re-install's
|
|
572
575
|
# fast-forward, a merge into a local edit) warns when the bundle's file
|