samagotchi 0.159.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/architecture.md +5 -6
  4. data/docs/cli.md +1 -1
  5. data/docs/guardrails.md +21 -2
  6. data/docs/internals/attached-context.md +4 -2
  7. data/docs/internals/bridge.md +7 -0
  8. data/docs/internals/engine.md +7 -1
  9. data/docs/memory.md +41 -13
  10. data/docs/scheduling.md +16 -3
  11. data/docs/sessions.md +3 -1
  12. data/lib/samagotchi/bridge.rb +11 -2
  13. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  14. data/lib/samagotchi/bundle_command.rb +1 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  17. data/lib/samagotchi/client_id.rb +4 -0
  18. data/lib/samagotchi/command_intake.rb +56 -0
  19. data/lib/samagotchi/context_absorber.rb +6 -3
  20. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  21. data/lib/samagotchi/context_candidates.rb +11 -8
  22. data/lib/samagotchi/context_command.rb +9 -3
  23. data/lib/samagotchi/context_poller.rb +5 -2
  24. data/lib/samagotchi/context_sources.rb +6 -4
  25. data/lib/samagotchi/engine/events.rb +13 -2
  26. data/lib/samagotchi/engine/guardrails.rb +3 -4
  27. data/lib/samagotchi/engine.rb +1 -0
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/events.rb +71 -0
  30. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  31. data/lib/samagotchi/hook_routes.rb +0 -1
  32. data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
  33. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  34. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  35. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  36. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  37. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  38. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  39. data/lib/samagotchi/memory_bundle/status.rb +2 -2
  40. data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
  41. data/lib/samagotchi/memory_orphans.rb +14 -4
  42. data/lib/samagotchi/memory_paths.rb +29 -6
  43. data/lib/samagotchi/memory_usage.rb +23 -12
  44. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  45. data/lib/samagotchi/plugin/sessions.rb +4 -1
  46. data/lib/samagotchi/project_command.rb +58 -20
  47. data/lib/samagotchi/project_id.rb +102 -0
  48. data/lib/samagotchi/project_id_clash.rb +62 -0
  49. data/lib/samagotchi/project_init.rb +214 -10
  50. data/lib/samagotchi/project_scope.rb +11 -2
  51. data/lib/samagotchi/schedules.rb +99 -39
  52. data/lib/samagotchi/session.rb +17 -2
  53. data/lib/samagotchi/session_access/local.rb +8 -5
  54. data/lib/samagotchi/session_access/remote.rb +2 -1
  55. data/lib/samagotchi/session_access.rb +4 -1
  56. data/lib/samagotchi/session_commands.rb +7 -3
  57. data/lib/samagotchi/session_continue.rb +10 -6
  58. data/lib/samagotchi/session_manager/spawn.rb +9 -2
  59. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  60. data/lib/samagotchi/state_format.rb +118 -0
  61. data/lib/samagotchi/tools/context_read.rb +7 -2
  62. data/lib/samagotchi/tools/delegate.rb +10 -2
  63. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  64. data/lib/samagotchi/update_command.rb +2 -2
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +5 -4
  67. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  68. data/lib/samagotchi/worker.rb +18 -6
  69. data/lib/samagotchi/worker_inbound.rb +21 -2
  70. metadata +5 -1
@@ -1,20 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "atomic_file"
5
+ require_relative "context_sources"
4
6
  require_relative "guardrails/path_resolve"
7
+ require_relative "memory_orphans"
5
8
  require_relative "memory_paths"
6
9
  require_relative "paths"
10
+ require_relative "project_id"
11
+ require_relative "session_paths"
7
12
 
8
13
  module Samagotchi
9
- # The shared decisions for `chi project init` and chi web's project picker.
14
+ # The shared decisions for `chi project init` and chi web's project picker,
15
+ # and every text they refuse or report with (the CLI prints them). A new
16
+ # marker gets an id (ProjectId); #adopt gives an older marker one, #new_id
17
+ # a copied folder its own.
10
18
  module ProjectInit
11
- Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
19
+ # +id+: the project's id (ProjectId), nil for none (a git repository, a
20
+ # marker without one, a refusal).
21
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message, :id) do
22
+ def initialize(kind:, reason:, root:, top:, nested_in:, message:, id: nil) = super
23
+ end
24
+
25
+ # A folder keyed by the project key (MemoryPaths.key_for): under +base+,
26
+ # named +prefix+ + the key.
27
+ Place = Data.define(:base, :prefix) do
28
+ def path(key) = File.join(base, "#{prefix}#{key}")
29
+ end
12
30
 
13
31
  MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
32
 
15
33
  module_function
16
34
 
17
- def call(dir, home:)
35
+ # Marks +dir+ (a new marker gets an id), or says why not. A folder whose
36
+ # path key already names a folder (memories kept while it was no
37
+ # project, keyed by the same path) is marked without an id, so nothing
38
+ # it has goes out of reach: #adopt moves those and writes the id.
39
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
40
+ def call(dir, home:, env: ENV, state_dir: nil)
18
41
  dir = File.expand_path(dir.to_s)
19
42
  found = MemoryPaths.lookup(dir, home: home)
20
43
  if found.git?
@@ -32,19 +55,94 @@ module Samagotchi
32
55
  # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
56
  enclosing = found.marker? ? found.root : nil
34
57
  if enclosing == Guardrails::PathResolve.real(dir)
58
+ id_part = found.id ? " (id #{found.id})" : ""
35
59
  return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
36
- message: "#{Paths.tilde(enclosing)} is already a chi project")
60
+ message: "#{Paths.tilde(enclosing)} is already a chi project#{id_part}", id: found.id)
37
61
  end
38
62
 
39
- write_marker(dir)
63
+ id = write_marker(dir, places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)))
40
64
  root = File.realpath(dir)
41
65
  # No message: each surface words its own success (the CLI's report, the web's navigation).
42
- Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
66
+ Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil, id: id)
43
67
  rescue SystemCallError => e
44
68
  marker = File.join(dir, MemoryPaths::MARKER)
45
69
  write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
70
  end
47
71
 
72
+ # `chi project init` (the CLI only: chi web's New-project row calls
73
+ # #call, which never moves folders): #call, and for a marked folder
74
+ # with no valid id, its adoption of one. The folders keyed by its path
75
+ # key (its memories, its project context sources, the skills bundle's
76
+ # kept versions) move to the id, each leaving a link by its old name so
77
+ # an older chi, keying by path, finds the same folder; then the id is
78
+ # written (a crash before that leaves the path key reaching the moved
79
+ # folders through the links, and a rerun finishes with the same id).
80
+ # Refused while a running session belongs to the project: its worker
81
+ # could write a memory under the old name between a move and its link.
82
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
83
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+, +project+, +id+)
84
+ # A folder #call marked without an id is adopted the same way.
85
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+,
86
+ # +project+, +cwd+, +id+): a running one in the project (by the root it
87
+ # stored, else its folder's) refuses
88
+ # @return [Outcome] :adopted (its message the report), :created (its
89
+ # message what moved, nil when nothing did) or #call's
90
+ def adopt(dir, home:, env: ENV, state_dir: nil, sessions: [])
91
+ state_dir ||= SessionPaths.default_state_dir(env: env)
92
+ outcome = call(dir, home: home, env: env, state_dir: state_dir)
93
+ return outcome unless %i[already_marked created].include?(outcome.kind) && outcome.id.nil?
94
+
95
+ root = outcome.root
96
+ running = Array(sessions).select do |row|
97
+ row.live && (row.project ? row.project == root : MemoryPaths.lookup(row.cwd.to_s, home: home).root == root)
98
+ end
99
+ unless running.empty?
100
+ return refused(:running, root, "#{Paths.tilde(root)} has running sessions " \
101
+ "(#{running.map { |row| row.id[0, 8] }.join(", ")}): stop them " \
102
+ "(chi sessions stop ID) and rerun chi project init, which moves the " \
103
+ "project's folders while no session can write to them")
104
+ end
105
+
106
+ adopted = adopt_id(root, env: env, state_dir: state_dir)
107
+ return adopted unless outcome.kind == :created && adopted.kind == :adopted
108
+
109
+ # A new project: the CLI words its success; the report keeps what moved.
110
+ moved = adopted.message.lines.drop(1).join.chomp
111
+ outcome.with(id: adopted.id, message: moved.empty? ? nil : moved)
112
+ rescue SystemCallError => e
113
+ write_failed("couldn't adopt an id for #{Paths.tilde(outcome&.root || dir.to_s)}: #{e.message}")
114
+ end
115
+
116
+ # `chi project init --new-id`: a fresh id for a marked folder (a copied
117
+ # one, going its own way), its folders copied from the key it had to the
118
+ # new id (the copy keeps what it had; the other folder keeps its own).
119
+ # @return [Outcome] :new_id (its message the report) or :refused
120
+ def new_id(dir, home:, env: ENV, state_dir: nil)
121
+ dir = File.expand_path(dir.to_s)
122
+ found = MemoryPaths.lookup(dir, home: home)
123
+ unless found.marker? && found.root == Guardrails::PathResolve.real(dir)
124
+ return refused(:not_marked, nil, "#{Paths.tilde(dir)} is not a marked chi project folder: --new-id gives " \
125
+ "one (made with chi project init) an id of its own")
126
+ end
127
+
128
+ root = found.root
129
+ old_key = MemoryPaths.key_for(root: root, id: found.id)
130
+ id = ProjectId.generate(File.basename(root))
131
+ lines = ["#{Paths.tilde(root)} has a chi project id of its own now: #{id} (was #{found.id || "none: keyed by its path"})"]
132
+ places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)).each do |place|
133
+ from = place.path(old_key)
134
+ next unless File.directory?(from)
135
+
136
+ to = place.path(id)
137
+ copy_folder(File.realpath(from), to)
138
+ lines << "copied #{Paths.tilde(from)} to #{Paths.tilde(to)}"
139
+ end
140
+ write_id(root, id)
141
+ Outcome.new(kind: :new_id, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
142
+ rescue SystemCallError => e
143
+ write_failed("couldn't give #{Paths.tilde(dir)} a new id: #{e.message}")
144
+ end
145
+
48
146
  # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
147
  # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
148
  def ensure_folder(dir)
@@ -54,16 +152,122 @@ module Samagotchi
54
152
  write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
153
  end
56
154
 
155
+ # The folders a project key names: its memories, its project context
156
+ # sources (where the workers keep them: ContextSources.root), and the
157
+ # skills bundle's kept versions of its project skills (history/
158
+ # project-<key>, the bundle's own data dir).
159
+ # @return [Array<Place>]
160
+ def places(env:, state_dir:)
161
+ [Place.new(base: MemoryPaths.projects_dir(env: env), prefix: ""),
162
+ Place.new(base: File.join(ContextSources.root(state_dir: state_dir), "projects"), prefix: ""),
163
+ Place.new(base: File.join(File.dirname(state_dir), "plugins", "skills", "history"), prefix: "project-")]
164
+ end
165
+
166
+ def adopt_id(root, env:, state_dir:)
167
+ problem = ProjectId.read(root).problem
168
+ path_key = MemoryPaths.key_for_root(root)
169
+ # Before the id: what MemoryOrphans finds for the project keyed by its path.
170
+ orphans = MemoryOrphans.find(env: env, cwd: root)
171
+ list = places(env: env, state_dir: state_dir)
172
+ id = resumed_id(list.first, path_key) || ProjectId.generate(File.basename(root))
173
+ taken = list.map { |place| [place.path(path_key), place.path(id)] }
174
+ .find { |from, to| real_dir?(from) && (File.exist?(to) || File.symlink?(to)) }
175
+ if taken
176
+ return refused(:id_taken, root, "#{Paths.tilde(taken[1])} already exists: chi project init can't move " \
177
+ "#{Paths.tilde(taken[0])} there; nothing moved")
178
+ end
179
+
180
+ lines = ["#{Paths.tilde(root)} now has the chi project id #{id}: its memories and project context stay " \
181
+ "with it when the folder moves or is renamed"]
182
+ lines << "#{problem}: replaced" if problem
183
+ list.each { |place| lines.concat(move_folder(place.path(path_key), place.path(id))) }
184
+ write_id(root, id)
185
+ if orphans
186
+ lines << "memories from an earlier path: #{orphans.candidates.map { |c| Paths.tilde(c) }.join(", ")} (this " \
187
+ "folder's name with another path's hash; if they are this project's, move their files into " \
188
+ "#{Paths.tilde(list.first.path(id))})"
189
+ end
190
+ Outcome.new(kind: :adopted, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
191
+ end
192
+
193
+ # The id an adoption that crashed before writing it chose: the path
194
+ # key already a link to an id folder beside it.
195
+ def resumed_id(place, path_key)
196
+ link = place.path(path_key)
197
+ return nil unless File.symlink?(link)
198
+
199
+ target = File.readlink(link)
200
+ target if target.match?(ProjectId::PATTERN) && File.directory?(place.path(target))
201
+ end
202
+
203
+ def real_dir?(path) = File.directory?(path) && !File.symlink?(path)
204
+
205
+ # Move +from+ to +to+ and leave a link named +from+ (relative: the
206
+ # folder may move with its parent). Nothing to move (no folder, or a
207
+ # link already): no move and no link. A folder made again at +from+
208
+ # between the move and the link has its files moved over (none
209
+ # replaced), then gives way to the link; what was in both stays.
210
+ # @return [Array<String>] the report's lines
211
+ def move_folder(from, to)
212
+ return [] unless real_dir?(from)
213
+
214
+ File.rename(from, to)
215
+ begin
216
+ File.symlink(File.basename(to), from)
217
+ rescue Errno::EEXIST
218
+ Dir.children(from).each do |name|
219
+ File.rename(File.join(from, name), File.join(to, name)) unless File.exist?(File.join(to, name))
220
+ end
221
+ unless Dir.empty?(from)
222
+ return ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)}; files written to the old folder meanwhile " \
223
+ "that #{Paths.tilde(to)} has too stay in #{Paths.tilde(from)}"]
224
+ end
225
+
226
+ Dir.rmdir(from)
227
+ File.symlink(File.basename(to), from)
228
+ end
229
+ ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)} (the old name links to it, for an older chi)"]
230
+ end
231
+
232
+ # +to+ made a copy of +from+'s files, without the copied project's
233
+ # claim (.root: ProjectIdClash).
234
+ def copy_folder(from, to)
235
+ FileUtils.mkdir_p(to)
236
+ FileUtils.cp_r(File.join(from, "."), to)
237
+ FileUtils.rm_f(File.join(to, ".root"))
238
+ end
239
+
240
+ def write_id(root, id)
241
+ marker = File.join(root, MemoryPaths::MARKER)
242
+ AtomicFile.write(marker, ProjectId.with_id(File.read(marker), id))
243
+ end
244
+
245
+ def refused(reason, root, message)
246
+ Outcome.new(kind: :refused, reason: reason, root: root, top: root, nested_in: nil, message: message)
247
+ end
248
+
57
249
  def write_failed(message)
58
250
  Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
251
  end
60
- private_class_method :write_failed
61
252
 
62
- def write_marker(dir)
253
+ # @param places [Array<Place>] the folders a key names (#places)
254
+ # @return [String, nil] the new marker's id; nil when a folder already
255
+ # has the path key (#call)
256
+ def write_marker(dir, places)
63
257
  marker = File.join(dir, MemoryPaths::MARKER)
64
258
  FileUtils.mkdir_p(File.dirname(marker))
65
- File.write(marker, MARKER_TEXT)
259
+ root = File.realpath(dir)
260
+ key = MemoryPaths.key_for_root(root)
261
+ if places.any? { |place| File.exist?(place.path(key)) || File.symlink?(place.path(key)) }
262
+ File.write(marker, MARKER_TEXT)
263
+ return nil
264
+ end
265
+
266
+ id = ProjectId.generate(File.basename(root))
267
+ File.write(marker, ProjectId.with_id(MARKER_TEXT, id))
268
+ id
66
269
  end
67
- private_class_method :write_marker
270
+ private_class_method :write_failed, :write_marker, :refused, :adopt_id, :resumed_id, :real_dir?, :move_folder,
271
+ :copy_folder, :write_id
68
272
  end
69
273
  end
@@ -18,10 +18,19 @@ module Samagotchi
18
18
  return nil if dir.empty?
19
19
  return cache[dir] if cache&.key?(dir)
20
20
 
21
- found = MemoryPaths.lookup(File.expand_path(dir))
22
- root = found.project? ? found.root : nil
21
+ root = project_for(dir)&.root
23
22
  cache[dir] = root if cache
24
23
  root
25
24
  end
25
+
26
+ # +dir+'s project (MemoryPaths::ProjectLookup: its root and, for a
27
+ # marked project, its id), or nil in none.
28
+ def project_for(dir)
29
+ dir = dir.to_s
30
+ return nil if dir.empty?
31
+
32
+ found = MemoryPaths.lookup(File.expand_path(dir))
33
+ found.project? ? found : nil
34
+ end
26
35
  end
27
36
  end
@@ -9,6 +9,7 @@ require "fileutils"
9
9
  require_relative "atomic_file"
10
10
  require_relative "archive_store"
11
11
  require_relative "client_id"
12
+ require_relative "command_intake"
12
13
  require_relative "config"
13
14
  require_relative "continue_offer"
14
15
  require_relative "delivery"
@@ -18,6 +19,7 @@ require_relative "session_chain"
18
19
  require_relative "session_inbox"
19
20
  require_relative "session_manager"
20
21
  require_relative "session_row"
22
+ require_relative "state_format"
21
23
  require_relative "worker_link"
22
24
 
23
25
  module Samagotchi
@@ -79,15 +81,20 @@ module Samagotchi
79
81
  # puts the clock back to it.
80
82
  Entry = Data.define(:id, :title, :anchor, :head, :mode, :prompt, :recurrence, :busy, :catch_up,
81
83
  :keep_links, :max_failures, :max_skips, :next_due, :due_was, :last_fired, :last_result,
82
- :firing_since, :failures, :skips, :enabled, :created_at) do
84
+ :firing_since, :failures, :skips, :enabled, :created_at, :extra) do
83
85
  # The recurrence comes back from JSON with string keys: every reader of
84
86
  # it below matches on `recurrence[:kind]`, so it is symbolised here once.
85
- # A file written by a newer chi may carry keys this one does not know:
86
- # they are dropped rather than raising in Data#initialize.
87
- def self.from_h(hash)
88
- fields = hash.transform_keys(&:to_sym).select { |key| Entry.members.include?(key) }
87
+ def self.from_record(record)
88
+ fields = record.attributes
89
89
  recurrence = fields[:recurrence]
90
- new(**fields, recurrence: recurrence.is_a?(Hash) ? recurrence.transform_keys(&:to_sym) : recurrence)
90
+ fields[:recurrence] = recurrence.transform_keys(&:to_sym) if recurrence.is_a?(Hash)
91
+ new(**fields, extra: record.extra)
92
+ end
93
+
94
+ def to_h
95
+ fields = super
96
+ extra = fields.delete(:extra) || {}
97
+ extra.transform_keys(&:to_s).merge(fields.transform_keys(&:to_s))
91
98
  end
92
99
 
93
100
  def due?(now)
@@ -111,6 +118,34 @@ module Samagotchi
111
118
  def target_words = "#{mode} · #{head.to_s[0, 8]}"
112
119
  end
113
120
 
121
+ FORMAT = StateFormat.new(
122
+ version: VERSION,
123
+ members: [
124
+ StateFormat.required(:id),
125
+ StateFormat.added(:title, in_version: VERSION, default: nil),
126
+ StateFormat.required(:anchor),
127
+ StateFormat.added(:head, in_version: VERSION, default: ->(values) { values[:anchor] }),
128
+ StateFormat.required(:mode),
129
+ StateFormat.required(:prompt),
130
+ StateFormat.required(:recurrence),
131
+ StateFormat.added(:busy, in_version: VERSION, default: "skip"),
132
+ StateFormat.added(:catch_up, in_version: VERSION, default: "once"),
133
+ StateFormat.added(:keep_links, in_version: VERSION, default: 5),
134
+ StateFormat.added(:max_failures, in_version: VERSION, default: 3),
135
+ StateFormat.added(:max_skips, in_version: VERSION, default: 12),
136
+ StateFormat.required(:next_due),
137
+ StateFormat.added(:due_was, in_version: VERSION, default: nil),
138
+ StateFormat.added(:last_fired, in_version: VERSION, default: nil),
139
+ StateFormat.added(:last_result, in_version: VERSION, default: nil),
140
+ StateFormat.added(:firing_since, in_version: VERSION, default: nil),
141
+ StateFormat.added(:failures, in_version: VERSION, default: 0),
142
+ StateFormat.added(:skips, in_version: VERSION, default: 0),
143
+ StateFormat.added(:enabled, in_version: VERSION, default: true),
144
+ StateFormat.added(:created_at, in_version: VERSION, default: nil),
145
+ StateFormat.extra(:extra, in_version: VERSION)
146
+ ]
147
+ )
148
+
114
149
  # What one entry did in a tick: "fired", "skipped", "missed", "failed" or
115
150
  # "dry" (a --dry-run's "would fire"). +detail+ is the reason.
116
151
  Outcome = Data.define(:id, :title, :status, :detail, :entry) do
@@ -144,7 +179,7 @@ module Samagotchi
144
179
  # @param include_disabled [Boolean] the disabled and the fired one-offs too
145
180
  # @return [Array<Entry>] every entry, the earliest due first
146
181
  def entries(include_disabled: false)
147
- list = read.sort_by { |entry| [entry.next_due.to_s, entry.id] }
182
+ list = read.entries.grep(Entry).sort_by { |entry| [entry.next_due.to_s, entry.id] }
148
183
  include_disabled ? list : list.select(&:enabled)
149
184
  end
150
185
 
@@ -155,7 +190,7 @@ module Samagotchi
155
190
  given = id_or_name.to_s.strip
156
191
  return nil if given.empty?
157
192
 
158
- list = read
193
+ list = read.entries.grep(Entry)
159
194
  exact = list.find { |entry| entry.id == given }
160
195
  return exact if exact
161
196
 
@@ -188,6 +223,9 @@ module Samagotchi
188
223
 
189
224
  prompt = prompt.to_s
190
225
  raise Usage, "a schedule needs a prompt" if prompt.strip.empty?
226
+ # It fires from a schedule, never a person: a shell line would be
227
+ # refused at every firing (CommandIntake); a /word one is the model's text.
228
+ raise Usage, CommandIntake::REFUSED if CommandIntake.shell_line?(prompt)
191
229
  if prompt.bytesize > PROMPT_MAX_BYTES
192
230
  raise Usage, "the prompt is #{prompt.bytesize} bytes; the limit is 16 KiB (#{PROMPT_MAX_BYTES} bytes)"
193
231
  end
@@ -224,9 +262,9 @@ module Samagotchi
224
262
  max_skips: int_or_default(max_skips, "schedule.default_max_skips", 12, min: 1),
225
263
  next_due: next_due_for(recurrence, from: now).utc.iso8601, due_was: nil,
226
264
  last_fired: nil, last_result: nil, firing_since: nil,
227
- failures: 0, skips: 0, enabled: true, created_at: now.utc.iso8601
265
+ failures: 0, skips: 0, enabled: true, created_at: now.utc.iso8601, extra: {}
228
266
  )
229
- write_under_lock { |list| list << entry }
267
+ write_under_lock { |file| file.entries << entry }
230
268
  entry
231
269
  end
232
270
 
@@ -295,7 +333,8 @@ module Samagotchi
295
333
  return Tick.new(locked: true, outcomes: [])
296
334
  end
297
335
 
298
- list = read
336
+ file = read
337
+ list = file.entries.grep(Entry)
299
338
  # Ordered by the moment each entry is due, then id: with one chain
300
339
  # firing per tick, the entries of that chain take turns (a skipped
301
340
  # entry keeps its `next_due`, so the earliest waiting one goes next).
@@ -308,14 +347,14 @@ module Samagotchi
308
347
  # One session listing for the whole tick: a chain walk re-globbing
309
348
  # every session per call is the tick's dominant cost.
310
349
  index = Index.new(@state_dir)
311
- updated = list.dup
350
+ updated = file.entries.dup
312
351
  # One firing per chain per tick. A second chain entry of the same chain
313
352
  # would continue the link the first one just made: that link has no
314
353
  # worker lock yet and is not in this tick's index, so the busy check
315
354
  # cannot see it, and its prompt would be archived before it ever ran.
316
355
  fired_chains = {}
317
356
  due.each do |entry|
318
- position = updated.index { |candidate| candidate.id == entry.id }
357
+ position = updated.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
319
358
  verdict = check(entry, index)
320
359
  if verdict
321
360
  kind, reason = verdict
@@ -347,7 +386,7 @@ module Samagotchi
347
386
  pending << advanced
348
387
  fired_chains[chain_key(index, entry)] = true if entry.mode == CHAIN
349
388
  end
350
- write(list: updated) unless dry_run
389
+ write(file: file.with(entries: updated)) unless dry_run
351
390
  end
352
391
 
353
392
  pending.each { |entry| outcomes << fire_and_record(entry, index) }
@@ -522,21 +561,30 @@ module Samagotchi
522
561
  # than reading as empty: a write starting from an empty list would rewrite
523
562
  # the file and lose every schedule on it. Listing stays lenient.
524
563
  def read(strict: false)
525
- return [] unless File.exist?(path)
564
+ return FORMAT.read({}, collection: "schedules") unless File.exist?(path)
526
565
 
527
- data = JSON.parse(File.read(path))
528
- Array(data["schedules"]).map { |hash| Entry.from_h(hash) }
529
- rescue JSON::ParserError => e
566
+ file = FORMAT.read(JSON.parse(File.read(path)), collection: "schedules")
567
+ file.with(entries: file.entries.map do |record|
568
+ if record.readable?
569
+ Entry.from_record(record)
570
+ else
571
+ Log.warn(:worker, "schedule_entry_unreadable",
572
+ id: record.raw.is_a?(Hash) ? record.raw["id"] : nil,
573
+ missing: record.missing.map(&:to_s))
574
+ record
575
+ end
576
+ end)
577
+ rescue JSON::ParserError, StateFormat::InvalidDocument => e
530
578
  Log.warn(:worker, "store_unreadable", error: e.class.name, msg: e.message)
531
579
  raise Corrupt, "#{path} is not readable JSON (#{e.message}): fix or remove it before changing schedules" if strict
532
580
 
533
- []
581
+ FORMAT.read({}, collection: "schedules")
534
582
  end
535
583
 
536
- def write(list:)
584
+ def write(file:)
537
585
  FileUtils.mkdir_p(@store_dir)
538
586
  FileUtils.mkdir_p(@state_dir)
539
- body = { "version" => VERSION, "updated_at" => Time.now.utc.iso8601, "schedules" => list.map(&:to_h) }
587
+ body = FORMAT.write(file, collection: "schedules", updated_at: Time.now.utc.iso8601)
540
588
  AtomicFile.write(path, "#{JSON.pretty_generate(body)}\n")
541
589
  end
542
590
 
@@ -546,9 +594,9 @@ module Samagotchi
546
594
  FileUtils.mkdir_p(@state_dir)
547
595
  File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
548
596
  lock.flock(File::LOCK_EX)
549
- list = read(strict: true)
550
- yield(list)
551
- write(list: list)
597
+ file = read(strict: true)
598
+ yield(file)
599
+ write(file: file)
552
600
  end
553
601
  end
554
602
 
@@ -558,15 +606,15 @@ module Samagotchi
558
606
  entry = lookup(id_or_name)
559
607
  return nil if entry.nil?
560
608
 
561
- write_under_lock do |list|
562
- position = list.index { |candidate| candidate.id == entry.id }
609
+ write_under_lock do |file|
610
+ position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
563
611
  next if position.nil?
564
612
 
565
613
  if delete
566
- list[position] = nil
567
- list.compact!
614
+ file.entries[position] = nil
615
+ file.entries.compact!
568
616
  else
569
- list[position] = entry.with(enabled: enabled, last_result: enabled ? "enabled" : "disabled")
617
+ file.entries[position] = entry.with(enabled: enabled, last_result: enabled ? "enabled" : "disabled")
570
618
  end
571
619
  end
572
620
  entry
@@ -800,10 +848,17 @@ module Samagotchi
800
848
  def fire_chain(entry, index)
801
849
  head, _reason = target_of(entry, index)
802
850
  return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "the chain is gone", entry: entry) if head.nil?
851
+ # One stored before #add refused it: its link would refuse the line,
852
+ # so it fails here, with no link started and nothing archived.
853
+ if CommandIntake.shell_line?(entry.prompt)
854
+ return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: CommandIntake::REFUSED, entry: entry)
855
+ end
803
856
 
804
857
  # `last:<head>` so SessionContinue.run's own TRIES/`:moved` loop follows
805
- # a continue a human made between here and its lock.
806
- link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir)
858
+ # a continue a human made between here and its lock. The link's first
859
+ # turn is the schedule's (its origin), as a same-mode firing's is.
860
+ link = SessionContinue.run("last:#{head}", prompt: entry.prompt, state_dir: @state_dir,
861
+ client_id: ClientId.schedule(entry.id))
807
862
  Outcome.new(id: entry.id, title: entry.title, status: "fired", detail: "new link #{link.id[0, 8]}",
808
863
  entry: entry.with(head: link.id))
809
864
  rescue SessionContinue::Refused => e
@@ -820,6 +875,11 @@ module Samagotchi
820
875
  # listing and this moment archives the target it just resolved to.
821
876
  dir = Session.session_dir(target, state_dir: @state_dir)
822
877
  return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: "archived", entry: entry) if ArchiveStore.archived?(dir)
878
+ # One stored before #add refused it: the Bridge would refuse it, but a
879
+ # delivery that falls back to the input file is accepted.
880
+ if CommandIntake.shell_line?(entry.prompt)
881
+ return Outcome.new(id: entry.id, title: entry.title, status: "failed", detail: CommandIntake::REFUSED, entry: entry)
882
+ end
823
883
 
824
884
  result = SessionManager.deliver_turn(target, prompt: entry.prompt,
825
885
  client_id: ClientId.schedule(entry.id),
@@ -843,23 +903,23 @@ module Samagotchi
843
903
  # moves for a skip or a failure. `due_was` is untouched — run-now is not a
844
904
  # scheduled occurrence, so there is no occurrence to put the clock back to.
845
905
  def record_refusal(entry, status, reason)
846
- write_under_lock do |list|
847
- position = list.index { |candidate| candidate.id == entry.id }
906
+ write_under_lock do |file|
907
+ position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
848
908
  next if position.nil?
849
909
 
850
- stored = list[position]
851
- list[position] = status == "skipped" ? skipped(stored, reason) : failed(stored, reason)
910
+ stored = file.entries[position]
911
+ file.entries[position] = status == "skipped" ? skipped(stored, reason) : failed(stored, reason)
852
912
  end
853
913
  end
854
914
 
855
915
  # The result goes back under the lock before any pruning, so a prune that
856
916
  # raises midway cannot lose the new head.
857
917
  def record(entry, outcome)
858
- write_under_lock do |list|
859
- position = list.index { |candidate| candidate.id == entry.id }
918
+ write_under_lock do |file|
919
+ position = file.entries.index { |candidate| candidate.is_a?(Entry) && candidate.id == entry.id }
860
920
  next if position.nil?
861
921
 
862
- stored = list[position]
922
+ stored = file.entries[position]
863
923
  updated = if outcome.status == "fired"
864
924
  # Any success clears the skip and failure counters: twelve
865
925
  # skips spread over months do not kill a healthy schedule.
@@ -877,7 +937,7 @@ module Samagotchi
877
937
  # tried comes back, so the next tick tries it again. max_skips and
878
938
  # max_failures bound how often that happens.
879
939
  updated = updated.with(next_due: stored.due_was) if outcome.status != "fired" && !stored.due_was.nil?
880
- list[position] = updated
940
+ file.entries[position] = updated
881
941
  end
882
942
  end
883
943
 
@@ -166,6 +166,11 @@ module Samagotchi
166
166
  # deleted folder no longer leads to its repository. nil outside a repo,
167
167
  # and in files written before the field existed (see #project_root).
168
168
  SessionField.plain("project_root", nil),
169
+ # The project's id (ProjectId: a marked project's `id:`), saved beside
170
+ # project_root at creation, else nil: its memories and project context
171
+ # sources are keyed by it (MemoryPaths.key_for), so a session resumed
172
+ # after its folder moved finds them under the id, not its dead path.
173
+ SessionField.plain("project_id", nil, optional: true),
169
174
  # Memories the session was started with (--memory) and memories hidden
170
175
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
171
176
  # Names as given; the engine normalizes them.
@@ -271,6 +276,14 @@ module Samagotchi
271
276
  # chi web's create) can name it (`--wait --turn T ID`). nil for a
272
277
  # session created idle, and for one saved before the field existed.
273
278
  SessionField.plain("first_turn_id", nil, optional: true),
279
+ # Who sent that prompt when no person did (a ClientId: a delegating
280
+ # parent's delegate:<parent8>, a schedule's chain link
281
+ # system:schedule:<id>): the first turn's origin, so the command
282
+ # intake treats it as theirs (CommandIntake) and last_turn names
283
+ # them. nil for a person's prompt (chi send --new, the web, a TUI, a
284
+ # continue) and an idle session. Set once, by spawn_session; a
285
+ # continue and a fork don't carry it.
286
+ SessionField.plain("first_turn_client_id", nil, optional: true),
274
287
  # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
275
288
  # nil for any other session. Set once, at spawn (VerifyFresh.start).
276
289
  # Autoloaded on the first one (the bundle test kit's load budget).
@@ -290,7 +303,7 @@ module Samagotchi
290
303
  attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
291
304
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
292
305
  :used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
293
- :continues, :first_turn_id
306
+ :continues, :first_turn_id, :first_turn_client_id, :project_id
294
307
  attr_writer :project_root, :delegate
295
308
  attr_reader :appended_prompt_files, :written_by
296
309
 
@@ -572,6 +585,7 @@ module Samagotchi
572
585
  review: nil, now: Time.now)
573
586
  own = setup.to_h
574
587
  own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
588
+ project = ProjectScope.project_for(working_directory)
575
589
  Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
576
590
  now = now.iso8601(3)
577
591
  resolved_test = if test_run.nil?
@@ -591,7 +605,8 @@ module Samagotchi
591
605
  status: STATUS_IDLE,
592
606
  first_preview: "",
593
607
  test_run: resolved_test,
594
- project_root: ProjectScope.root_for(working_directory),
608
+ project_root: project&.root,
609
+ project_id: project&.id,
595
610
  preloaded_memory_names: preloaded_memory_names,
596
611
  muted_memory_names: muted_memory_names,
597
612
  parent_id: parent_id,