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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/docs/architecture.md +5 -6
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +21 -2
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +7 -0
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +41 -13
- data/docs/scheduling.md +16 -3
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +1 -0
- 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 +4 -0
- data/lib/samagotchi/command_intake.rb +56 -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_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/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- 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 +5 -3
- data/lib/samagotchi/memory_bundle/status.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
- 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/schedules.rb +99 -39
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/local.rb +8 -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 +9 -2
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- 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/session_access_routes.rb +3 -3
- data/lib/samagotchi/worker.rb +18 -6
- data/lib/samagotchi/worker_inbound.rb +21 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/schedules.rb
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 { |
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
564
|
+
return FORMAT.read({}, collection: "schedules") unless File.exist?(path)
|
|
526
565
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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(
|
|
584
|
+
def write(file:)
|
|
537
585
|
FileUtils.mkdir_p(@store_dir)
|
|
538
586
|
FileUtils.mkdir_p(@state_dir)
|
|
539
|
-
body =
|
|
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
|
-
|
|
550
|
-
yield(
|
|
551
|
-
write(
|
|
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 |
|
|
562
|
-
position =
|
|
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
|
-
|
|
567
|
-
|
|
614
|
+
file.entries[position] = nil
|
|
615
|
+
file.entries.compact!
|
|
568
616
|
else
|
|
569
|
-
|
|
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
|
-
|
|
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 |
|
|
847
|
-
position =
|
|
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 =
|
|
851
|
-
|
|
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 |
|
|
859
|
-
position =
|
|
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 =
|
|
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
|
-
|
|
940
|
+
file.entries[position] = updated
|
|
881
941
|
end
|
|
882
942
|
end
|
|
883
943
|
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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:
|
|
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,
|