samagotchi 0.158.0 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +12 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "atomic_file"
|
|
4
|
+
require_relative "memory_paths"
|
|
5
|
+
require_relative "paths"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Two folders with one chi project id: a marked folder copied (cp -R)
|
|
9
|
+
# keeps its marker, so the copy shares the original's memories. Told at
|
|
10
|
+
# session start (Engine#announce_load_events!, label "memory", beside
|
|
11
|
+
# MemoryOrphans), at every session start while it lasts.
|
|
12
|
+
#
|
|
13
|
+
# The claim: an id-keyed memories folder's `.root` names the folder that
|
|
14
|
+
# last started a session with it (one line; a dotfile, so no *.md glob
|
|
15
|
+
# lists it). Written only here, at session start, never by the key
|
|
16
|
+
# resolver: a read-only command or a memory call must not write.
|
|
17
|
+
ProjectIdClash = Data.define(:root, :other, :id) do
|
|
18
|
+
# The one text for the condition.
|
|
19
|
+
def message
|
|
20
|
+
"#{Paths.tilde(root)} and #{Paths.tilde(other)} share chi project id #{id} (a copied folder?): they share " \
|
|
21
|
+
"memories; `chi project init --new-id` gives this one its own."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class ProjectIdClash
|
|
26
|
+
CLAIM = ".root"
|
|
27
|
+
|
|
28
|
+
# +cwd+'s project, when a marked one with an id: a clash when the claim
|
|
29
|
+
# names another folder that still has a marker with this id; otherwise
|
|
30
|
+
# the claim is set to this root (when it changes; a moved folder just
|
|
31
|
+
# takes it over). Only once the id folder exists: nothing is made for a
|
|
32
|
+
# project with no memories yet.
|
|
33
|
+
# @return [ProjectIdClash, nil]
|
|
34
|
+
def self.check(env: ENV, cwd: Dir.pwd, home: MemoryPaths.default_home)
|
|
35
|
+
found = MemoryPaths.lookup(cwd, home: home)
|
|
36
|
+
return nil unless found.id
|
|
37
|
+
|
|
38
|
+
dir = File.join(MemoryPaths.projects_dir(env: env), found.id)
|
|
39
|
+
return nil unless File.directory?(dir)
|
|
40
|
+
|
|
41
|
+
claim = File.join(dir, CLAIM)
|
|
42
|
+
other = File.file?(claim) ? File.read(claim, 4096).to_s.lines.first.to_s.strip : ""
|
|
43
|
+
return nil if other == found.root
|
|
44
|
+
return new(root: found.root, other: other, id: found.id) if same_project?(other, found.id, home)
|
|
45
|
+
|
|
46
|
+
AtomicFile.write(claim, "#{found.root}\n")
|
|
47
|
+
nil
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether +other+ still is a marked folder with +id+ (not gone, not
|
|
53
|
+
# given another id).
|
|
54
|
+
def self.same_project?(other, id, home)
|
|
55
|
+
return false if other.empty? || !File.directory?(other)
|
|
56
|
+
|
|
57
|
+
found = MemoryPaths.lookup(other, home: home)
|
|
58
|
+
found.marker? && found.root == File.realpath(other) && found.id == id
|
|
59
|
+
end
|
|
60
|
+
private_class_method :same_project?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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
|
|
@@ -214,6 +214,7 @@ module Samagotchi
|
|
|
214
214
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
215
215
|
chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
|
|
216
216
|
chi project [init [DIR]] this folder's project; init makes a folder in no git repository one
|
|
217
|
+
chi schedule <add|list|show|tick|run-now|enable|disable|cancel> durable schedules that outlive every worker
|
|
217
218
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
|
218
219
|
chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
|
|
219
220
|
chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "cli/command"
|
|
7
|
+
require_relative "cli/exit"
|
|
8
|
+
require_relative "cli/flags"
|
|
9
|
+
require_relative "schedules"
|
|
10
|
+
require_relative "session"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# `chi schedule`: durable schedules that outlive every worker (docs/scheduling.md).
|
|
14
|
+
# A tick run by cron, systemd or chi web's hub (`chi schedule tick`) fires the
|
|
15
|
+
# entries whose time has come against a session: the next link of its chain
|
|
16
|
+
# (the default), or a queued turn in the session itself.
|
|
17
|
+
# bin/chi dispatches here before OptionParser; this machine only.
|
|
18
|
+
class ScheduleCommand
|
|
19
|
+
include CLI::Command
|
|
20
|
+
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi schedule <add|list|show|tick|run-now|enable|disable|cancel> [flags]
|
|
23
|
+
|
|
24
|
+
add --session ID (--continues ID) (-m|--message|--prompt TEXT) [--title TEXT]
|
|
25
|
+
one of: --every 30m | --weekday 1 --time 09:00 | --date 2026-11-20T09:00
|
|
26
|
+
[--mode chain|same] [--catch-up once|none] [--keep-links N] [--max-failures N] [--max-skips N]
|
|
27
|
+
list [--all] [--format text|json]
|
|
28
|
+
show ID
|
|
29
|
+
tick [--dry-run] [--format text|json]
|
|
30
|
+
run-now ID
|
|
31
|
+
enable ID | disable ID | cancel ID
|
|
32
|
+
|
|
33
|
+
Recurrence: --every 45m / 2h / 1d (UTC interval math: an `every 1d` schedule drifts
|
|
34
|
+
an hour across a DST change — use --weekday for a wall-clock time);
|
|
35
|
+
--weekday 1..7 (1=Mon) with --time HH:MM in the machine's local zone;
|
|
36
|
+
--date one local moment.
|
|
37
|
+
Modes: chain (default) runs the prompt as the next link of the session's chain;
|
|
38
|
+
same queues it as a turn in the session itself.
|
|
39
|
+
A busy target is skipped and retried at the next tick; after max_skips skips
|
|
40
|
+
(default 12) or max_failures failures (default 3) the schedule is disabled.
|
|
41
|
+
TEXT
|
|
42
|
+
|
|
43
|
+
SUBCOMMANDS = %w[add list show tick run-now enable disable cancel].freeze
|
|
44
|
+
|
|
45
|
+
# A usage error names the subcommand it was given (CLI::Command's
|
|
46
|
+
# usage_error builds its line from this).
|
|
47
|
+
def command_name = SUBCOMMANDS.include?(@argv.first.to_s) ? "chi schedule #{@argv.first}" : "chi schedule"
|
|
48
|
+
|
|
49
|
+
FLAGS = {
|
|
50
|
+
"add" => CLI::Flags.new(args: true) do |f|
|
|
51
|
+
f.value "--session", "--continues", key: :target
|
|
52
|
+
f.value "-m", "--message", "--prompt", key: :prompt
|
|
53
|
+
f.value "--title"
|
|
54
|
+
f.value "--every"
|
|
55
|
+
f.value "--weekday"
|
|
56
|
+
f.value "--time"
|
|
57
|
+
f.value "--date"
|
|
58
|
+
f.value "--mode"
|
|
59
|
+
f.value "--catch-up"
|
|
60
|
+
f.value "--keep-links"
|
|
61
|
+
f.value "--max-failures"
|
|
62
|
+
f.value "--max-skips"
|
|
63
|
+
end,
|
|
64
|
+
"list" => CLI::Flags.new(args: false) do |f|
|
|
65
|
+
f.switch "--all", key: :include_disabled
|
|
66
|
+
f.value "--format"
|
|
67
|
+
end,
|
|
68
|
+
"show" => CLI::Flags.new(args: true),
|
|
69
|
+
"tick" => CLI::Flags.new(args: false) do |f|
|
|
70
|
+
f.switch "--dry-run"
|
|
71
|
+
f.value "--format"
|
|
72
|
+
end,
|
|
73
|
+
"run-now" => CLI::Flags.new(args: true),
|
|
74
|
+
"enable" => CLI::Flags.new(args: true),
|
|
75
|
+
"disable" => CLI::Flags.new(args: true),
|
|
76
|
+
"cancel" => CLI::Flags.new(args: true)
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# @param argv [Array<String>] the arguments after "schedule"
|
|
80
|
+
# @param schedules [Schedules, nil] the store; default this machine's
|
|
81
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, schedules: nil)
|
|
82
|
+
@argv = argv.dup
|
|
83
|
+
@stdout = stdout
|
|
84
|
+
@stderr = stderr
|
|
85
|
+
@schedules = schedules || Schedules.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Integer] exit status: 0 ok, 1 a missing session or schedule,
|
|
89
|
+
# 2 a usage error
|
|
90
|
+
def run
|
|
91
|
+
sub = @argv[0]
|
|
92
|
+
if sub.nil? || HELP_WORDS.include?(sub)
|
|
93
|
+
@stdout.puts(USAGE)
|
|
94
|
+
return 0
|
|
95
|
+
end
|
|
96
|
+
return usage_error("unknown subcommand #{sub}") unless SUBCOMMANDS.include?(sub)
|
|
97
|
+
|
|
98
|
+
parsed = parse_flags(FLAGS.fetch(sub), @argv[1..])
|
|
99
|
+
return parsed if parsed.is_a?(Integer)
|
|
100
|
+
|
|
101
|
+
@opts = parsed.options
|
|
102
|
+
@args = parsed.args
|
|
103
|
+
|
|
104
|
+
case sub
|
|
105
|
+
when "add" then add
|
|
106
|
+
when "list" then list
|
|
107
|
+
when "show" then show
|
|
108
|
+
when "tick" then tick
|
|
109
|
+
when "run-now" then act { |id| @schedules.run_now(id) }
|
|
110
|
+
when "enable" then act { |id| @schedules.enable(id) }
|
|
111
|
+
when "disable" then act { |id| @schedules.disable(id) }
|
|
112
|
+
when "cancel" then act { |id| @schedules.cancel(id) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def add
|
|
119
|
+
if @opts[:target].to_s.strip.empty? && !@args.empty?
|
|
120
|
+
return usage_error("add takes the session with --session ID, not #{@args.map { |arg| arg.inspect }.join(" ")}")
|
|
121
|
+
end
|
|
122
|
+
return usage_error("add needs --session ID (the session the schedule runs against)") if @opts[:target].to_s.strip.empty?
|
|
123
|
+
return usage_error("add needs a prompt: -m \"...\"") if @opts[:prompt].to_s.strip.empty?
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
recurrence = Schedules.recurrence(every: @opts[:every], weekday: @opts[:weekday], time: @opts[:time], date: @opts[:date])
|
|
127
|
+
entry = @schedules.add(target: @opts[:target], prompt: @opts[:prompt], recurrence: recurrence,
|
|
128
|
+
title: @opts[:title], mode: @opts[:mode] || Schedules::CHAIN,
|
|
129
|
+
catch_up: @opts[:catch_up], keep_links: @opts[:keep_links],
|
|
130
|
+
max_failures: @opts[:max_failures], max_skips: @opts[:max_skips])
|
|
131
|
+
rescue Schedules::Usage => e
|
|
132
|
+
return usage_error(e.message)
|
|
133
|
+
rescue Session::AmbiguousId => e
|
|
134
|
+
return failed("which session? #{e.message}")
|
|
135
|
+
rescue Session::NotFound
|
|
136
|
+
return failed("no session #{@opts[:target]}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@stdout.puts("#{entry.id} added: #{entry.target_words} · #{entry.recurrence_words} · next #{clock(entry.next_due)}")
|
|
140
|
+
CLI::Exit::OK
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def list
|
|
144
|
+
entries = @schedules.entries(include_disabled: @opts[:include_disabled] ? true : false)
|
|
145
|
+
if @opts[:format] == "json"
|
|
146
|
+
@stdout.puts(JSON.pretty_generate(entries.map { |entry| entry_line_json(entry) }))
|
|
147
|
+
return CLI::Exit::OK
|
|
148
|
+
end
|
|
149
|
+
if entries.empty?
|
|
150
|
+
@stdout.puts("no schedules")
|
|
151
|
+
return CLI::Exit::OK
|
|
152
|
+
end
|
|
153
|
+
entries.each { |entry| @stdout.puts(entry_line(entry)) }
|
|
154
|
+
CLI::Exit::OK
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def show
|
|
158
|
+
id = @args.first || @opts[:id]
|
|
159
|
+
return usage_error("show needs a schedule id") if id.to_s.strip.empty?
|
|
160
|
+
|
|
161
|
+
entry = @schedules.lookup(id)
|
|
162
|
+
return failed("no schedule #{id}") if entry.nil?
|
|
163
|
+
|
|
164
|
+
@stdout.puts("#{entry.id} #{entry.title || "(untitled)"}")
|
|
165
|
+
@stdout.puts(" anchor: #{entry.anchor} head: #{entry.head} mode: #{entry.mode}")
|
|
166
|
+
@stdout.puts(" recurrence: #{entry.recurrence_words} next due: #{clock(entry.next_due)}")
|
|
167
|
+
@stdout.puts(" last fired: #{clock(entry.last_fired)} last result: #{entry.last_result || "-"}")
|
|
168
|
+
@stdout.puts(" skips: #{entry.skips} of #{entry.max_skips} failures: #{entry.failures} of #{entry.max_failures}")
|
|
169
|
+
@stdout.puts(" keep_links: #{entry.keep_links} enabled: #{entry.enabled}")
|
|
170
|
+
@stdout.puts(" prompt: #{entry.prompt}")
|
|
171
|
+
CLI::Exit::OK
|
|
172
|
+
rescue Schedules::Usage => e
|
|
173
|
+
usage_error(e.message)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def tick
|
|
177
|
+
result = @schedules.tick(dry_run: @opts[:dry_run] ? true : false)
|
|
178
|
+
if @opts[:format] == "json"
|
|
179
|
+
@stdout.puts(JSON.pretty_generate({ "locked" => result.locked, "outcomes" => result.outcomes.map { |outcome| { "id" => outcome.id, "status" => outcome.status, "detail" => outcome.detail } } }))
|
|
180
|
+
return CLI::Exit::OK
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if result.locked
|
|
184
|
+
@stdout.puts("no schedules due (another tick holds the lock)")
|
|
185
|
+
return CLI::Exit::OK
|
|
186
|
+
end
|
|
187
|
+
if result.outcomes.empty?
|
|
188
|
+
@stdout.puts("no schedules due")
|
|
189
|
+
return CLI::Exit::OK
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
result.outcomes.each do |outcome|
|
|
193
|
+
word = outcome.status == "dry" ? "would fire" : outcome.status
|
|
194
|
+
@stdout.puts("#{outcome.id} #{word} #{outcome.detail}")
|
|
195
|
+
end
|
|
196
|
+
CLI::Exit::OK
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def act
|
|
200
|
+
id = @args.first
|
|
201
|
+
return usage_error("#{@argv[0]} needs a schedule id") if id.to_s.strip.empty?
|
|
202
|
+
|
|
203
|
+
result = yield(id)
|
|
204
|
+
return failed("no schedule #{id}") if result.nil?
|
|
205
|
+
|
|
206
|
+
if result.is_a?(Schedules::Outcome)
|
|
207
|
+
@stdout.puts("#{id}: #{result.status} · #{result.detail}")
|
|
208
|
+
return result.status == "failed" ? CLI::Exit::FAILED : CLI::Exit::OK
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
@stdout.puts("#{id} #{@argv[0]}: ok")
|
|
212
|
+
CLI::Exit::OK
|
|
213
|
+
rescue Schedules::Usage => e
|
|
214
|
+
usage_error(e.message)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def failed(message)
|
|
218
|
+
error_line(message)
|
|
219
|
+
CLI::Exit::FAILED
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def clock(iso)
|
|
223
|
+
return "-" if iso.nil? || iso.to_s.empty?
|
|
224
|
+
|
|
225
|
+
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
226
|
+
rescue ArgumentError
|
|
227
|
+
iso.to_s
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def entry_line(entry)
|
|
231
|
+
note = ""
|
|
232
|
+
note += " skips #{entry.skips} of #{entry.max_skips} (#{skip_window(entry)})" if entry.skips.positive?
|
|
233
|
+
note += " disabled after #{entry.failures} failures" unless entry.enabled
|
|
234
|
+
"#{entry.id} #{entry.mode} #{entry.recurrence_words} #{clock(entry.next_due)} #{entry.head[0, 8]}#{note}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A skip budget as a time window: 3 of 12 at a 5 minute tick is about 1 hour
|
|
238
|
+
# of patience, which is what the count really measures.
|
|
239
|
+
def skip_window(entry)
|
|
240
|
+
minutes = entry.max_skips * Schedules.tick_interval_minutes
|
|
241
|
+
hours = minutes / 60.0
|
|
242
|
+
hours >= 1 ? "≈#{(hours * 10).round / 10} h at a #{Schedules.tick_interval_minutes} min tick" : "≈#{minutes} min"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def entry_line_json(entry)
|
|
246
|
+
entry.to_h.transform_keys(&:to_s).merge(
|
|
247
|
+
"recurrence_words" => entry.recurrence_words,
|
|
248
|
+
"skip_window" => skip_window(entry)
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|