samagotchi 0.159.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -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" then session_id && e["session_id"] == session_id && e["key"] == key
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" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
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
@@ -17,14 +17,20 @@ module Samagotchi
17
17
  # (a repo or rule answer would hold in every worktree, for every child),
18
18
  # when:
19
19
  # write, edit: a path is outside root (Outside: tmp dirs and memory
20
- # files never are);
20
+ # files never are); so is a path a plugin tool's targets: declare
21
+ # the call writes (writes: true), the same declaration
22
+ # ProtectedPaths reads — a call is judged by what its targets say it
23
+ # acts on, not by its tool's name;
21
24
  # execute, task_create: mutating git runs in a dir outside root
22
25
  # (ShellGitDirs), or a command that isn't read-only starts outside
23
26
  # root (its cwd:, else Dir.pwd) or names a path in the repository's
24
27
  # other checkouts (the main one and sibling worktrees) or its
25
28
  # common git dir; or it runs git that changes what all checkouts
26
29
  # share, from anywhere (SharedGit: config, update-ref, worktree add,
27
- # stash clear, tags, another branch's delete).
30
+ # stash clear, tags, another branch's delete). A plugin tool's
31
+ # declared command: is checked for what chi can read for sure only:
32
+ # SharedGit, and whether the call's cwd: is outside root (its text
33
+ # may not be shell; Targets#rule_command).
28
34
  # Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
29
35
  # read-only commands never ask, nor do ones that only write into a tmp
30
36
  # dir (> / >>, cp's destination, tee; Targets#read_only_but_tmp_writes?). Paths resolve under chi's env and the
@@ -67,12 +73,39 @@ module Samagotchi
67
73
 
68
74
  def crosses?(targets, root)
69
75
  case targets.tool
70
- when "write", "edit" then targets.paths.any? { |path| Outside.outside?(path, root: root) }
76
+ when "write", "edit" then paths_cross?(targets, root)
71
77
  when *SHELL_TOOLS then shell_crosses?(targets, root)
72
- else false
78
+ else
79
+ # chi's other own tools (read, memory_write, …) are unchanged:
80
+ # memory_write carries writes: for ProtectedPaths, but its paths
81
+ # are memory files (Outside exempts them) and its `..` entry names
82
+ # asked for nothing here before.
83
+ return false if Targets::CORE_TOOLS.include?(targets.tool)
84
+
85
+ plugin_crosses?(targets, root)
73
86
  end
74
87
  end
75
88
 
89
+ def paths_cross?(targets, root)
90
+ targets.paths.any? { |path| Outside.outside?(path, root: root) }
91
+ end
92
+
93
+ # A plugin tool, by what its targets: say it acts on (Targets#writes?,
94
+ # #command, #cwd), not by its name: paths its targets say it writes
95
+ # like a write's, and a declared command like a shell's — but for the
96
+ # command only what chi can read for sure: git that changes what every
97
+ # checkout shares (SharedGit), and where the call runs (its cwd:). A
98
+ # plugin's command text may not be shell (Targets#rule_command), so
99
+ # the shell half's git-dir, cd and redirect reads are left out; a
100
+ # plugin that wants more can bring a rule of its own.
101
+ def plugin_crosses?(targets, root)
102
+ return true if targets.writes? && paths_cross?(targets, root)
103
+ return :shared if targets.command && SharedGit.changes?(targets.command, own_branch: @branch.call)
104
+ return true if targets.command && targets.cwd && Outside.outside?(targets.cwd, root: root)
105
+
106
+ false
107
+ end
108
+
76
109
  def shell_crosses?(targets, root)
77
110
  return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
78
111
  return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
@@ -4,7 +4,8 @@ require_relative "path_resolve"
4
4
 
5
5
  module Samagotchi
6
6
  module Guardrails
7
- # Core checks on what the file tools (write, edit, memory_write) touch,
7
+ # Core checks on what a call writes (Targets#writes?: write, edit,
8
+ # memory_write, and a plugin tool whose targets: say writes: true),
8
9
  # after the hooks and before the rules. Paths are compared with
9
10
  # symlinks resolved (on the longest existing parent, `..` taken
10
11
  # physically, a dangling symlink followed: PathResolve.reals, the
@@ -14,7 +15,8 @@ module Samagotchi
14
15
  # hooks, rules, manifests), attached context (a source's
15
16
  # command runs later, outside the gate; chi context writes
16
17
  # it) and System 1's saved runs (a label there may train a
17
- # model); nothing legitimate writes there through the file tools.
18
+ # model); nothing legitimate writes there through a tool
19
+ # that says it writes.
18
20
  # ask (once / session): config.yml, home.json (the chi home: the
19
21
  # Ruby every chi launch boots), the chi shim (ChiShim: the
20
22
  # `chi` every tool runs) and the plain hooks dir. The
@@ -24,7 +26,6 @@ module Samagotchi
24
26
  # execute can still write all of them (the guardrails bundle adds a
25
27
  # text match for that).
26
28
  class ProtectedPaths
27
- FILE_TOOLS = %w[write edit memory_write].freeze
28
29
  SOURCE = "core"
29
30
 
30
31
  # @param store_dir [String] <state dir>/guardrails
@@ -50,10 +51,12 @@ module Samagotchi
50
51
  ].select { |_, root, _| root }
51
52
  end
52
53
 
53
- # Vote on +verdict+ (its targets).
54
+ # Vote on +verdict+ (its targets): a call whose targets say it writes
55
+ # (Targets#writes?: write, edit, memory_write, or a plugin tool's
56
+ # targets: writes: true) is judged on its paths.
54
57
  def check(verdict)
55
58
  targets = verdict.targets
56
- return verdict unless targets && FILE_TOOLS.include?(targets.tool)
59
+ return verdict unless targets&.writes?
57
60
 
58
61
  paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
59
62
  @deny.each do |rule, root, reason|
@@ -90,6 +90,15 @@ module Samagotchi
90
90
  # reads it)
91
91
  attr_reader :label
92
92
 
93
+ # @return [Boolean] whether the call writes the paths its targets name:
94
+ # chi's own write, edit and memory_write, and a plugin tool whose
95
+ # targets: say writes: true (a literal true only). The checks that
96
+ # guard a write (ProtectedPaths, ChildBoundary) ask this rather than
97
+ # the tool's name, so a plugin tool that acts like one is judged
98
+ # like one.
99
+ attr_reader :writes
100
+ alias writes? writes
101
+
93
102
  # @return [Boolean] a memory_write call that removes a memory (remove:
94
103
  # true), for a rule's memory: remove
95
104
  attr_reader :memory_remove
@@ -126,6 +135,7 @@ module Samagotchi
126
135
  args = nil
127
136
  acts_as = nil
128
137
  label = nil
138
+ writes = false
129
139
  env = {}
130
140
  case tool
131
141
  when *SHELL_TOOLS
@@ -134,14 +144,17 @@ module Samagotchi
134
144
  given = call[:cwd].to_s.strip
135
145
  cwd = File.expand_path(given, base) unless given.empty?
136
146
  when "write", "edit"
147
+ writes = true
137
148
  paths.concat(absolutes(call[:path], base))
138
149
  when "read"
139
150
  paths.concat(absolutes(call[:content], base))
140
151
  when "memory_write"
152
+ writes = true
141
153
  paths << memory_path(call, model_key)
142
154
  else
143
155
  given = plugin_targets(call, registry, surface)
144
156
  command = given[:command]
157
+ writes = given[:writes]
145
158
  given_cwd = given[:cwd].to_s.strip
146
159
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
147
160
  paths.concat(given[:paths].flat_map { |path| absolutes(path, cwd) })
@@ -154,25 +167,29 @@ module Samagotchi
154
167
  memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
155
168
  memory = tool == "memory_write" ? memory_facts(call) : nil
156
169
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, env: env, repo_root: context.repo_root(cwd),
157
- repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
170
+ repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, writes: writes,
171
+ memory_remove: memory_remove,
158
172
  memory_overlay: memory_overlay, memory: memory,
159
173
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
160
174
  chi_dirs: -> { chi_dirs(context, base) })
161
175
  end
162
176
 
163
177
  # What a plugin tool's targets: callable says the call acts on:
164
- # {command:, paths:, cwd:, acts_as:, args:, label:}, each optional; a callable
178
+ # {command:, paths:, writes:, cwd:, acts_as:, args:, label:}, each
179
+ # optional; a callable
165
180
  # that raises or gives something else counts as nothing (and is
166
181
  # logged). args: counts when it is a Hash; acts_as: is dropped (and
167
182
  # logged) when it names a core tool or another bundle's tool, so a
168
183
  # call can't pass for one and match (or reuse the approvals of) its
169
- # rules. label: counts when it is one short line (.label_of). A
184
+ # rules. label: counts when it is one short line (.label_of). writes:
185
+ # counts only for a literal true: the tool says its paths are written,
186
+ # and the checks that guard write/edit judge them (.writes?). A
170
187
  # missing surface (Tools::MissingSurface) is raised, never taken as
171
188
  # nothing: call_tool's acts_as would go missing, and with it the
172
189
  # mcp_* rules on the call (fail-open).
173
190
  def self.plugin_targets(call, registry, surface = nil)
174
191
  entry = entry_of(call[:name], registry, surface)
175
- none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
192
+ none = { command: nil, paths: [], writes: false, cwd: nil, acts_as: nil, args: nil, label: nil }
176
193
  return none unless entry && !entry.core? && entry.targets
177
194
 
178
195
  given = entry.targets_of(call, surface)
@@ -180,7 +197,8 @@ module Samagotchi
180
197
 
181
198
  given = given.transform_keys(&:to_sym)
182
199
  command = given[:command].to_s
183
- { command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
200
+ { command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), writes: given[:writes] == true,
201
+ cwd: given[:cwd],
184
202
  acts_as: acts_as_of(given[:acts_as], entry, registry, surface), args: given[:args].is_a?(Hash) ? given[:args] : nil,
185
203
  label: label_of(given[:label], entry) }
186
204
  rescue Tools::MissingSurface
@@ -318,10 +336,11 @@ module Samagotchi
318
336
  # @param env [Hash{String=>String}] the call's env: (.env_of)
319
337
  def initialize(tool:, command:, paths:, cwd:, repo_root:, env: {}, repo: nil, args: nil, acts_as: nil,
320
338
  label: nil, model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil,
321
- memory_remove: false, memory_overlay: false, memory: nil)
339
+ memory_remove: false, memory_overlay: false, memory: nil, writes: false)
322
340
  @env = env
323
341
  @acts_as = acts_as
324
342
  @label = label
343
+ @writes = writes
325
344
  @memory_remove = memory_remove
326
345
  @memory_overlay = memory_overlay
327
346
  @memory = memory
@@ -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,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/version"
3
4
  require_relative "config"
4
5
  require_relative "session"
5
6
  require_relative "version"
@@ -38,9 +39,13 @@ module Samagotchi
38
39
  end
39
40
  end
40
41
 
41
- # The live workers on another version than +version+ (unknown counts).
42
+ # The live workers older than the target chi (unknown versions count).
43
+ # Compat's N-1 verdict is for hints; these callers are adopting the new code.
42
44
  def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
43
- workers(state_dir: state_dir).reject { |w| w.version == version }
45
+ target = Gem::Version.new(version)
46
+ workers(state_dir: state_dir).select do |worker|
47
+ worker.version.nil? || !Gem::Version.correct?(worker.version) || Gem::Version.new(worker.version) < target
48
+ end
44
49
  end
45
50
 
46
51
  # The /api/info of a chi web on host:port, or nil when nothing (or not
@@ -50,7 +50,8 @@ module Samagotchi
50
50
  # @param chi_version [String] the chi this install is for (the Installer's;
51
51
  # a caller that checks its own bundles against another version passes it)
52
52
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
53
- force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION)
53
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION,
54
+ refuse_mismatch: false)
54
55
  @name = name
55
56
  @bundle_dir = source_dir
56
57
  @manifest = manifest
@@ -66,11 +67,42 @@ module Samagotchi
66
67
  @dry_run = dry_run
67
68
  @dev = dev
68
69
  @chi_version = chi_version
70
+ @refuse_mismatch = refuse_mismatch
71
+ end
72
+
73
+ # Raises for the first plugin or script the manifest names that the
74
+ # bundle doesn't have (or ships as a link), writing nothing: the
75
+ # Installer runs it before its first write.
76
+ # @raise [Installer::InstallError]
77
+ def check_named_files
78
+ plugin_source
79
+ scripts_sources
80
+ end
81
+
82
+ # The same early pass on the shas: the plugin and each script against the
83
+ # bundle's own bytes, before anything is written. Only when the caller
84
+ # refuses a mismatch (`chi bundle install/upgrade`; chi update, a profile
85
+ # and a dev install keep their warning), and not with --force, which
86
+ # installs a mismatch anyway.
87
+ # @raise [Installer::InstallError] one line per mismatched file
88
+ def check_named_checksums
89
+ return unless @refuse_mismatch && @strict && !@dev && !@force && @manifest
90
+
91
+ lines = []
92
+ if @manifest.plugin
93
+ lines << Installer.mismatch_refusal("plugin #{@manifest.plugin[:file]}", plugin_source,
94
+ @manifest.checksum_for_plugin, upgrade: @upgrade)
95
+ end
96
+ scripts_sources.each do |name, src|
97
+ lines << Installer.mismatch_refusal("script #{name}", src, declared_sha(:script, name), upgrade: @upgrade)
98
+ end
99
+ lines = lines.compact
100
+ raise Installer::InstallError, lines.join("\n") unless lines.empty?
69
101
  end
70
102
 
71
103
  # @return [Installed]
72
- # @raise [Installer::InstallError] the manifest names a plugin or
73
- # script the bundle doesn't have
104
+ # @raise [Installer::InstallError] the same, when #check_named_files
105
+ # wasn't run first
74
106
  def run
75
107
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
76
108
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
@@ -115,7 +147,11 @@ module Samagotchi
115
147
  files.each do |file|
116
148
  @results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
117
149
  end
118
- return if @dry_run
150
+ if @dry_run
151
+ edits = Trash.edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
152
+ @warnings << "Would copy to the trash first: #{edits.join(", ")}" unless edits.empty?
153
+ return
154
+ end
119
155
 
120
156
  @trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
121
157
  FileUtils.rm_rf(dir)
@@ -133,38 +169,53 @@ module Samagotchi
133
169
  plugin = @manifest&.plugin
134
170
  return {} unless plugin
135
171
 
136
- src = File.join(@bundle_dir, plugin[:file])
137
- raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
138
- raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
139
-
172
+ src = plugin_source
140
173
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
141
174
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
142
175
  end
143
176
  { plugin[:file] => src }
144
177
  end
145
178
 
146
- # The manifest's scripts (a dev install warns of one it can't run).
179
+ # The manifest's plugin file in the source, checked; nil without one.
180
+ def plugin_source
181
+ plugin = @manifest&.plugin
182
+ return unless plugin
183
+
184
+ src = File.join(@bundle_dir, plugin[:file])
185
+ raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
186
+ raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
187
+
188
+ src
189
+ end
190
+
191
+ # The manifest's scripts in the source, checked.
147
192
  # @return [Hash{String => String}] file name => the source file
148
- def scripts_incoming
149
- scripts = @manifest&.scripts || {}
150
- scripts.keys.to_h do |file|
193
+ def scripts_sources
194
+ (@manifest&.scripts || {}).keys.to_h do |file|
151
195
  src = File.join(@bundle_dir, "scripts", file)
152
196
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
153
197
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
154
198
 
155
- @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
156
199
  [file, src]
157
200
  end
158
201
  end
159
202
 
203
+ # The manifest's scripts (a dev install warns of one it can't run).
204
+ # @return [Hash{String => String}] file name => the source file
205
+ def scripts_incoming
206
+ scripts_sources.each do |file, src|
207
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
208
+ end
209
+ end
210
+
160
211
  # One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
161
212
  # replaced whole by +incoming+: each file's AssetState and its action
162
213
  # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
163
214
  # bundle), one result per incoming file (a dry run's DRY_WORDS of it,
164
215
  # and nothing written), an edit copied to the trash first (save_first),
165
- # then the folder replaced. A file the source dropped goes without a
166
- # word. A plugin or script whose sha256 differs from the declared one
167
- # warns (not a dev link: it is the source).
216
+ # then the folder replaced. A removed file is named in the summary.
217
+ # A plugin or script whose sha256 differs from the declared one warns
218
+ # (not a dev link: it is the source).
168
219
  # @param incoming [Hash{String => String}] name => the source file, in
169
220
  # the order the results list them
170
221
  # @return [Hash{String => String}] name => installed path ({} on a dry run)
@@ -173,21 +224,36 @@ module Samagotchi
173
224
  command = @upgrade && @provenance.installed? ? :upgrade : :install
174
225
  states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
175
226
  .to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
227
+ if @dry_run
228
+ copies = states.filter_map { |_, (asset, action)| asset.label if action&.save_first }
229
+ @warnings << "Would copy to the trash first: #{copies.join(", ")}" unless copies.empty?
230
+ end
176
231
  results = incoming.keys.to_h do |name|
177
232
  asset, action = states.fetch(name)
178
233
  [name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
179
234
  end
235
+ dropped = states.filter_map do |name, (asset, action)|
236
+ next unless action&.do == :remove
237
+
238
+ [result_key(kind_of_file, name),
239
+ Installer::FileResult.new(word: @dry_run ? :would_remove : :removed, state: asset, action: action,
240
+ note: "no longer in the bundle")]
241
+ end
180
242
  if @dry_run
181
243
  results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
244
+ dropped.each { |key, result| @results[key] = result }
182
245
  return {}
183
246
  end
184
247
 
185
248
  states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
186
249
  FileUtils.rm_rf(dir)
187
- return {} if incoming.empty?
250
+ if incoming.empty?
251
+ dropped.each { |key, result| @results[key] = result }
252
+ return {}
253
+ end
188
254
 
189
255
  FileUtils.mkdir_p(dir)
190
- incoming.to_h do |name, src|
256
+ installed = incoming.to_h do |name, src|
191
257
  dest = File.join(dir, name)
192
258
  place(src, dest)
193
259
  @results[result_key(kind_of_file, name)] = results[name]
@@ -198,6 +264,8 @@ module Samagotchi
198
264
  end
199
265
  [name, dest]
200
266
  end
267
+ dropped.each { |key, result| @results[key] = result }
268
+ installed
201
269
  end
202
270
 
203
271
  # The kind's folder and the record's shas for it.
@@ -2,38 +2,18 @@
2
2
 
3
3
  module Samagotchi
4
4
  module MemoryBundle
5
- # One class-file hook's metadata, as an older chi's installed record
6
- # (manifest.json) keeps it (chi no longer installs or loads them:
7
- # Manifest::HooksRetired; it reads them to retire, uninstall and name
8
- # them, and the trash selection takes #sha256): the file's sha256
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 event [String, nil] one event (manifest `event:`)
18
- # @param events [Array<String>, String, nil] the events (manifest
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
- on_error = h["on_error"].to_s.strip
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.
@@ -180,14 +180,13 @@ module Samagotchi
180
180
  %i[missing any] => ASSET_GONE
181
181
  }.freeze
182
182
 
183
- # An upgrade (--force too) over the record: a file it replaces is
184
- # updated, except a rule (an upgrade lists a replaced rule as installed:
185
- # the corpus's rule-installed).
183
+ # An upgrade (--force too) over the record: a file it replaces is updated.
186
184
  ASSET_UPGRADE = ASSET_INSTALL.merge(
187
185
  %i[pristine any] => ASSET_WRITE.with(word: :updated),
188
186
  %i[edited any] => ASSET_WRITE.with(word: :updated, save_first: true),
189
187
  %i[linked any] => ASSET_WRITE.with(word: :updated),
190
- %i[pristine rule] => ASSET_WRITE, %i[edited rule] => ASSET_WRITE.with(save_first: true),
188
+ %i[pristine rule] => ASSET_WRITE.with(word: :updated),
189
+ %i[edited rule] => ASSET_WRITE.with(word: :updated, save_first: true),
191
190
  %i[linked rule] => ASSET_WRITE
192
191
  ).freeze
193
192
 
@@ -11,6 +11,10 @@ module Samagotchi
11
11
  # the record's entry (R). Derived, never stored. FileActions says what each
12
12
  # acting command does in each state.
13
13
  #
14
+ # A file whose disk bytes are the text an interrupted run journaled
15
+ # (Provenance#write_pending) reads as the bundle's, at that run's
16
+ # version: owned (an entry made up if the record has none), its own base.
17
+ #
14
18
  # kind, with a source (install, upgrade, remerge, restore, plan):
15
19
  # :new not the bundle's, not on disk
16
20
  # :user_same not the bundle's, on disk with I's bytes
@@ -82,6 +86,7 @@ module Samagotchi
82
86
  # @return [FileState]
83
87
  def of(key, record:, provenance:, target_dir:, source_dir:, claimants: nil, shipped: true)
84
88
  entry = record&.files&.fetch(key, nil)
89
+ pending = provenance.pending_shas(key, target_dir: target_dir)
85
90
  paths = { base: provenance.base_path(key), current: File.join(target_dir, key),
86
91
  incoming: source_dir && File.join(source_dir, key) }
87
92
  mark = mark_of(entry)
@@ -90,6 +95,12 @@ module Samagotchi
90
95
  incoming: source_dir && shipped ? digest(paths[:incoming]) : nil, recorded: entry && Provenance.recorded_sha(entry[:checksum]),
91
96
  marked_incoming: mark == :none ? nil : entry[:incoming_sha]
92
97
  )
98
+ if !pending.empty? && pending.include?(digests.disk)
99
+ # An interrupted run put this text here (Provenance#write_pending): it is the bundle's at
100
+ # that version, owned and its own base, not the user's file or an edit of the recorded one.
101
+ entry = (entry || {}).merge(checksum: digests.disk)
102
+ digests = digests.with(base: digests.disk, recorded: digests.disk)
103
+ end
93
104
  shared = nil
94
105
  lookup = -> { shared ||= claimants ? claimants.call(key) : [] }
95
106
  state = FileState.new(key: key, kind: nil, mark: mark, digests: digests, entry: entry, paths: paths, claimants: lookup)
@@ -14,8 +14,11 @@ module Samagotchi
14
14
  # @param source [String, false, nil] the bundle the memory came from
15
15
  # ("· from <bundle>"): a name sets it, false drops it, nil keeps the
16
16
  # line's (memory_write and write/edit pass nil)
17
- def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
18
- index_path = index_path_for(scope)
17
+ # @param target_dir [String, nil] the folder whose index.md to edit, when
18
+ # it is not the one +scope+ resolves from the current folder (an
19
+ # uninstall of a bundle journaled into another project)
20
+ def self.update_index(scope, entry_name, byte_count, description = nil, source: nil, target_dir: nil)
21
+ index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
19
22
  return true unless index_path
20
23
 
21
24
  locked_write(File.dirname(index_path)) do |content|
@@ -39,8 +42,8 @@ module Samagotchi
39
42
  /^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
40
43
  end
41
44
 
42
- def self.remove_index(scope, entry_name)
43
- index_path = index_path_for(scope)
45
+ def self.remove_index(scope, entry_name, target_dir: nil)
46
+ index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
44
47
  return true unless index_path && File.exist?(index_path)
45
48
 
46
49
  locked_write(File.dirname(index_path)) do |content|
@@ -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