samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -20,10 +20,16 @@ module Samagotchi
|
|
|
20
20
|
|
|
21
21
|
STATUS_IDLE = "idle"
|
|
22
22
|
STATUS_RUNNING = "running"
|
|
23
|
-
STATUS_COMPLETED = "completed"
|
|
24
23
|
STATUS_ERROR = "error"
|
|
25
24
|
STATUS_STOPPED = "stopped"
|
|
26
25
|
|
|
26
|
+
# A stopped session's marker, <session dir>/stopped: a stop (chi
|
|
27
|
+
# sessions stop, from another process) creates it instead of rewriting
|
|
28
|
+
# the session file a worker may be saving at the same moment, and a
|
|
29
|
+
# resume removes it. A file from before the marker says "stopped" in
|
|
30
|
+
# its status field, which reads the same.
|
|
31
|
+
STOPPED_FILE = "stopped"
|
|
32
|
+
|
|
27
33
|
SORT_KEYS = %w[created_at updated_at].freeze
|
|
28
34
|
SORT_ORDERS = %w[asc desc].freeze
|
|
29
35
|
|
|
@@ -147,37 +153,57 @@ module Samagotchi
|
|
|
147
153
|
path = session_path(session_id, state_dir: state_dir)
|
|
148
154
|
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(path)
|
|
149
155
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
pending = symbolize_message_keys(pending) if pending.is_a?(Hash)
|
|
154
|
-
used_mems = data["used_memory_names"] || data["used_memories"] || []
|
|
155
|
-
new(
|
|
156
|
-
id: data.fetch("id"),
|
|
157
|
-
metadata_version: data.fetch("metadata_version", 1),
|
|
158
|
-
mode: data.fetch("mode"),
|
|
159
|
-
model_name: data.fetch("model_name"),
|
|
160
|
-
working_directory: data.fetch("working_directory"),
|
|
161
|
-
messages: messages,
|
|
162
|
-
created_at: data.fetch("created_at"),
|
|
163
|
-
updated_at: data.fetch("updated_at"),
|
|
164
|
-
status: data.fetch("status", STATUS_IDLE),
|
|
165
|
-
last_prompt: data.fetch("last_prompt", ""),
|
|
166
|
-
first_preview: data.fetch("first_preview", ""),
|
|
167
|
-
test_run: data.fetch("test_run", false),
|
|
168
|
-
pending_question: pending,
|
|
169
|
-
used_memory_names: Array(used_mems),
|
|
170
|
-
project_root: data["project_root"],
|
|
171
|
-
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
172
|
-
muted_memory_names: Array(data["muted_memory_names"]),
|
|
173
|
-
parent_id: data["parent_id"],
|
|
174
|
-
scratch: data.fetch("scratch", false),
|
|
175
|
-
last_turn: data["last_turn"]
|
|
176
|
-
)
|
|
156
|
+
session = from_h(JSON.parse(File.read(path)))
|
|
157
|
+
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
|
|
158
|
+
session
|
|
177
159
|
rescue JSON::ParserError => e
|
|
178
160
|
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
179
161
|
end
|
|
180
162
|
|
|
163
|
+
# A field a session file must have (KeyError without it).
|
|
164
|
+
REQUIRED = Object.new.freeze
|
|
165
|
+
|
|
166
|
+
# The fields of <id>.json, in the order #save writes them: key => the
|
|
167
|
+
# value for a file without it (REQUIRED for the ones it must have).
|
|
168
|
+
# .from_h and #to_h both go by this table; messages and
|
|
169
|
+
# pending_question change key type on the way (symbols in memory,
|
|
170
|
+
# strings on disk).
|
|
171
|
+
FIELDS = {
|
|
172
|
+
"metadata_version" => 1,
|
|
173
|
+
"id" => REQUIRED,
|
|
174
|
+
"mode" => REQUIRED,
|
|
175
|
+
"model_name" => REQUIRED,
|
|
176
|
+
"working_directory" => REQUIRED,
|
|
177
|
+
"messages" => [],
|
|
178
|
+
"created_at" => REQUIRED,
|
|
179
|
+
"updated_at" => REQUIRED,
|
|
180
|
+
"status" => STATUS_IDLE,
|
|
181
|
+
"last_prompt" => "",
|
|
182
|
+
"first_preview" => "",
|
|
183
|
+
"test_run" => false,
|
|
184
|
+
"pending_question" => nil,
|
|
185
|
+
"used_memory_names" => [],
|
|
186
|
+
"project_root" => nil,
|
|
187
|
+
"preloaded_memory_names" => [],
|
|
188
|
+
"muted_memory_names" => [],
|
|
189
|
+
"parent_id" => nil,
|
|
190
|
+
"scratch" => false,
|
|
191
|
+
"last_turn" => nil
|
|
192
|
+
}.freeze
|
|
193
|
+
|
|
194
|
+
# A session from a parsed session file. +messages+ false leaves the
|
|
195
|
+
# conversation out (the lists' lightweight summaries).
|
|
196
|
+
# @raise [KeyError] for a missing REQUIRED field
|
|
197
|
+
def self.from_h(data, messages: true)
|
|
198
|
+
attrs = FIELDS.to_h do |key, default|
|
|
199
|
+
[key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
|
|
200
|
+
end
|
|
201
|
+
attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
|
|
202
|
+
pending = attrs[:pending_question]
|
|
203
|
+
attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
|
|
204
|
+
new(**attrs)
|
|
205
|
+
end
|
|
206
|
+
|
|
181
207
|
# A prefix that names more than one session.
|
|
182
208
|
class AmbiguousId < ArgumentError; end
|
|
183
209
|
|
|
@@ -250,30 +276,9 @@ module Samagotchi
|
|
|
250
276
|
# An id that isn't one (hand-edited, or not ours) would be a path.
|
|
251
277
|
return nil unless valid_id?(data["id"])
|
|
252
278
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
id: data.fetch("id"),
|
|
257
|
-
metadata_version: data.fetch("metadata_version", 1),
|
|
258
|
-
mode: data.fetch("mode"),
|
|
259
|
-
model_name: data.fetch("model_name"),
|
|
260
|
-
working_directory: data.fetch("working_directory"),
|
|
261
|
-
messages: [],
|
|
262
|
-
created_at: data.fetch("created_at"),
|
|
263
|
-
updated_at: data.fetch("updated_at"),
|
|
264
|
-
status: data.fetch("status", STATUS_IDLE),
|
|
265
|
-
last_prompt: data.fetch("last_prompt", ""),
|
|
266
|
-
first_preview: data.fetch("first_preview", ""),
|
|
267
|
-
test_run: data.fetch("test_run", false),
|
|
268
|
-
pending_question: pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil,
|
|
269
|
-
used_memory_names: Array(used_mems),
|
|
270
|
-
project_root: data["project_root"],
|
|
271
|
-
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
272
|
-
muted_memory_names: Array(data["muted_memory_names"]),
|
|
273
|
-
parent_id: data["parent_id"],
|
|
274
|
-
scratch: data.fetch("scratch", false),
|
|
275
|
-
last_turn: data["last_turn"]
|
|
276
|
-
)
|
|
279
|
+
session = from_h(data, messages: false)
|
|
280
|
+
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
|
|
281
|
+
session
|
|
277
282
|
rescue JSON::ParserError, KeyError, SystemCallError
|
|
278
283
|
nil
|
|
279
284
|
end
|
|
@@ -290,45 +295,23 @@ module Samagotchi
|
|
|
290
295
|
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
291
296
|
compute_first_preview!
|
|
292
297
|
|
|
293
|
-
|
|
294
|
-
"metadata_version" => @metadata_version,
|
|
295
|
-
"id" => @id,
|
|
296
|
-
"mode" => @mode,
|
|
297
|
-
"model_name" => @model_name,
|
|
298
|
-
"working_directory" => @working_directory,
|
|
299
|
-
"messages" => @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) },
|
|
300
|
-
"created_at" => @created_at,
|
|
301
|
-
"updated_at" => @updated_at,
|
|
302
|
-
"status" => @status,
|
|
303
|
-
"last_prompt" => @last_prompt,
|
|
304
|
-
"first_preview" => @first_preview,
|
|
305
|
-
"test_run" => !!@test_run,
|
|
306
|
-
"pending_question" => @pending_question ? scrub_utf8(stringify_message_keys(@pending_question)) : nil,
|
|
307
|
-
"used_memory_names" => Array(@used_memory_names),
|
|
308
|
-
"project_root" => @project_root,
|
|
309
|
-
"preloaded_memory_names" => Array(@preloaded_memory_names),
|
|
310
|
-
"muted_memory_names" => Array(@muted_memory_names),
|
|
311
|
-
"parent_id" => @parent_id,
|
|
312
|
-
"scratch" => @scratch,
|
|
313
|
-
"last_turn" => @last_turn
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
|
|
298
|
+
AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
|
|
317
299
|
self
|
|
318
300
|
end
|
|
319
301
|
|
|
320
|
-
#
|
|
321
|
-
def
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
302
|
+
# The session as #save writes it (FIELDS, in their order).
|
|
303
|
+
def to_h
|
|
304
|
+
FIELDS.keys.to_h do |key|
|
|
305
|
+
value = case key
|
|
306
|
+
when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
307
|
+
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
308
|
+
when "test_run" then !!@test_run
|
|
309
|
+
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
310
|
+
Array(instance_variable_get(:"@#{key}"))
|
|
311
|
+
else instance_variable_get(:"@#{key}")
|
|
312
|
+
end
|
|
313
|
+
[key, value]
|
|
314
|
+
end
|
|
332
315
|
end
|
|
333
316
|
|
|
334
317
|
# Mark a session as errored.
|
|
@@ -339,11 +322,24 @@ module Samagotchi
|
|
|
339
322
|
session.save(state_dir: state_dir)
|
|
340
323
|
end
|
|
341
324
|
|
|
342
|
-
# Mark a session as stopped
|
|
325
|
+
# Mark a session as stopped (STOPPED_FILE); its session file stays as
|
|
326
|
+
# it is.
|
|
327
|
+
# @raise [ArgumentError] when there is no such session
|
|
343
328
|
def self.mark_stopped(session_id, state_dir: default_state_dir)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
329
|
+
raise ArgumentError, "Session not found: #{session_id}" unless exist?(session_id, state_dir: state_dir)
|
|
330
|
+
|
|
331
|
+
dir = session_dir(session_id, state_dir: state_dir)
|
|
332
|
+
FileUtils.mkdir_p(dir)
|
|
333
|
+
FileUtils.touch(File.join(dir, STOPPED_FILE))
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Remove the stop marker (a resume, before its worker starts).
|
|
337
|
+
def self.clear_stopped(session_id, state_dir: default_state_dir)
|
|
338
|
+
FileUtils.rm_f(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def self.stopped_marker?(session_id, state_dir: default_state_dir)
|
|
342
|
+
File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
|
|
347
343
|
end
|
|
348
344
|
|
|
349
345
|
# An id that is not a session id: it could name a path outside the
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_manager"
|
|
5
|
+
require_relative "cli/command"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# `chi sessions archive|unarchive`: hide sessions from every list and keep
|
|
@@ -9,6 +10,8 @@ module Samagotchi
|
|
|
9
10
|
# unarchive_session), one line per id. Delegated children follow their
|
|
10
11
|
# parent.
|
|
11
12
|
class SessionArchiveCommand
|
|
13
|
+
include CLI::Command
|
|
14
|
+
|
|
12
15
|
USAGE = {
|
|
13
16
|
"archive" => <<~TEXT,
|
|
14
17
|
Usage: chi sessions archive (ID|PREFIX)...
|
|
@@ -36,8 +39,9 @@ module Samagotchi
|
|
|
36
39
|
|
|
37
40
|
# @return [Integer] exit status: 0 all done, 1 any refused or unknown, 2 usage
|
|
38
41
|
def run
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
# Help anywhere wins, even after an unknown option.
|
|
43
|
+
if @argv.any? { |arg| HELP_WORDS.include?(arg) }
|
|
44
|
+
@stdout.puts(usage_text)
|
|
41
45
|
return 0
|
|
42
46
|
end
|
|
43
47
|
bad = @argv.find { |arg| arg.start_with?("-") }
|
|
@@ -91,17 +95,7 @@ module Samagotchi
|
|
|
91
95
|
false
|
|
92
96
|
end
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def error_line(text)
|
|
97
|
-
@stdout.flush
|
|
98
|
-
@stderr.puts(text)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def usage_error(message)
|
|
102
|
-
error_line("chi sessions #{@action}: #{message}")
|
|
103
|
-
@stderr.puts(USAGE.fetch(@action))
|
|
104
|
-
2
|
|
105
|
-
end
|
|
98
|
+
def command_name = "chi sessions #{@action}"
|
|
99
|
+
def usage_text = USAGE.fetch(@action)
|
|
106
100
|
end
|
|
107
101
|
end
|
|
@@ -30,7 +30,6 @@ module Samagotchi
|
|
|
30
30
|
ROLLBACK_COMMAND = "!rollback"
|
|
31
31
|
CONTINUE_COMMAND = TurnFlow::CONTINUE_COMMAND
|
|
32
32
|
SHELL_BANG_PREFIX = "!"
|
|
33
|
-
RESERVED_MODEL_ARGS = %w[clear default none off].freeze
|
|
34
33
|
ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
|
|
35
34
|
# /exit --delete: delete the session on the way out.
|
|
36
35
|
EXIT_DELETE_FLAG = "--delete"
|
|
@@ -355,10 +354,10 @@ module Samagotchi
|
|
|
355
354
|
|
|
356
355
|
if alias_name
|
|
357
356
|
return ["--alias requires a model name: #{ALIAS_USAGE}", false] if arg.empty?
|
|
358
|
-
return ["--alias cannot be combined with clear/default/none/off", false] if
|
|
357
|
+
return ["--alias cannot be combined with clear/default/none/off", false] if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
|
|
359
358
|
|
|
360
359
|
# Validate the alias before switching, so a bad one changes nothing.
|
|
361
|
-
invalid =
|
|
360
|
+
invalid = ConfigFile.model_alias_error(alias_name, arg)
|
|
362
361
|
return ["invalid alias: #{invalid}", false] if invalid
|
|
363
362
|
|
|
364
363
|
switch_model(arg, persist_default: persist_default)
|
|
@@ -378,7 +377,7 @@ module Samagotchi
|
|
|
378
377
|
else
|
|
379
378
|
return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
|
|
380
379
|
|
|
381
|
-
if
|
|
380
|
+
if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
|
|
382
381
|
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
383
382
|
|
|
384
383
|
switch_model(@default_model)
|
|
@@ -394,23 +393,6 @@ module Samagotchi
|
|
|
394
393
|
end
|
|
395
394
|
end
|
|
396
395
|
|
|
397
|
-
# Mirrors ConfigFile.write_model_alias!'s checks without writing.
|
|
398
|
-
# @return [String, nil] what is wrong with the alias name
|
|
399
|
-
def alias_name_error(alias_name, target)
|
|
400
|
-
ak = alias_name.strip
|
|
401
|
-
return "alias name is required" if ak.empty?
|
|
402
|
-
|
|
403
|
-
lk = ak.downcase
|
|
404
|
-
return "alias name '#{ak}' is reserved" if RESERVED_MODEL_ARGS.include?(lk)
|
|
405
|
-
return "alias name must not contain whitespace" if ak.match?(/\s/)
|
|
406
|
-
return "alias name must not start with '-'" if ak.start_with?("-")
|
|
407
|
-
return "alias name must not contain '/'" if ak.include?("/")
|
|
408
|
-
return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{ak}')" unless ak.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
|
|
409
|
-
return "alias must not point to itself" if lk == target.strip.downcase
|
|
410
|
-
|
|
411
|
-
nil
|
|
412
|
-
end
|
|
413
|
-
|
|
414
396
|
# Engine owns the switch (alias resolution, profile, kernel, client and
|
|
415
397
|
# the optional default persist); the session keeps the new model.
|
|
416
398
|
def switch_model(name, persist_default: false)
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_manager"
|
|
5
|
+
require_relative "cli/command"
|
|
6
|
+
require_relative "cli/flags"
|
|
5
7
|
|
|
6
8
|
module Samagotchi
|
|
7
9
|
# `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
|
|
8
10
|
# one line per id. A live worker's session is refused unless --force stops
|
|
9
11
|
# the worker first; a chi REPL's never is.
|
|
10
12
|
class SessionDeleteCommand
|
|
13
|
+
include CLI::Command
|
|
14
|
+
|
|
11
15
|
USAGE = <<~TEXT
|
|
12
16
|
Usage: chi sessions delete [--force] (ID|PREFIX)...
|
|
13
17
|
Deletes each session: its history, notes, images and queued input.
|
|
@@ -17,6 +21,7 @@ module Samagotchi
|
|
|
17
21
|
chi REPL is always refused
|
|
18
22
|
TEXT
|
|
19
23
|
PREVIEW_LIMIT = 60
|
|
24
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) { |f| f.switch "-f", "--force" }
|
|
20
25
|
|
|
21
26
|
# @param argv [Array<String>] the arguments after "sessions delete"
|
|
22
27
|
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
@@ -29,30 +34,24 @@ module Samagotchi
|
|
|
29
34
|
# @return [Integer] exit status: 0 all deleted, 1 any refused or
|
|
30
35
|
# unknown, 2 usage
|
|
31
36
|
def run
|
|
32
|
-
options = parse
|
|
33
|
-
return
|
|
37
|
+
options = parse
|
|
38
|
+
return options if options.is_a?(Integer)
|
|
34
39
|
|
|
35
40
|
options[:ids].uniq.map { |given| delete(given, force: options[:force]) }.all? ? 0 : 1
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
private
|
|
39
44
|
|
|
45
|
+
def command_name = "chi sessions delete"
|
|
46
|
+
|
|
47
|
+
# @return [Hash, Integer] the options, or the exit status after the
|
|
48
|
+
# help or a usage error
|
|
40
49
|
def parse
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
case arg
|
|
45
|
-
when "-h", "--help", "help"
|
|
46
|
-
@stdout.puts(USAGE)
|
|
47
|
-
return { help: true }
|
|
48
|
-
when "-f", "--force" then options[:force] = true
|
|
49
|
-
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
50
|
-
else options[:ids] << arg
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
return usage_error("give session ids") if options[:ids].empty?
|
|
50
|
+
parsed = parse_flags(FLAGS, @argv, force: false)
|
|
51
|
+
return parsed if parsed.is_a?(Integer)
|
|
52
|
+
return usage_error("give session ids") if parsed.args.empty?
|
|
54
53
|
|
|
55
|
-
options
|
|
54
|
+
parsed.options.merge(ids: parsed.args)
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
# @return [Boolean] whether the session is gone
|
|
@@ -88,18 +87,5 @@ module Samagotchi
|
|
|
88
87
|
rescue ArgumentError, JSON::ParserError
|
|
89
88
|
nil
|
|
90
89
|
end
|
|
91
|
-
|
|
92
|
-
# stderr isn't buffered, stdout is when it's a pipe: flush the lines
|
|
93
|
-
# already printed, so the output keeps the order of the ids given.
|
|
94
|
-
def error_line(text)
|
|
95
|
-
@stdout.flush
|
|
96
|
-
@stderr.puts(text)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def usage_error(message)
|
|
100
|
-
error_line("chi sessions delete: #{message}")
|
|
101
|
-
@stderr.puts(USAGE)
|
|
102
|
-
nil
|
|
103
|
-
end
|
|
104
90
|
end
|
|
105
91
|
end
|
|
@@ -233,7 +233,7 @@ module Samagotchi
|
|
|
233
233
|
.reject { |s| (!include_tests && s.test_run) || s.id == exclude }
|
|
234
234
|
.select { |s| root.nil? || in_folder?(s.working_directory, root) }
|
|
235
235
|
.filter_map do |s|
|
|
236
|
-
owner = session_owner(s.id, state_dir: sd)&.
|
|
236
|
+
owner = session_owner(s.id, state_dir: sd)&.kind
|
|
237
237
|
owned = owner == "worker"
|
|
238
238
|
next if live && !owned
|
|
239
239
|
|
|
@@ -277,7 +277,7 @@ module Samagotchi
|
|
|
277
277
|
owner = owners[sid]
|
|
278
278
|
next unless owner
|
|
279
279
|
|
|
280
|
-
raise OwnedByTUI, sid if owner
|
|
280
|
+
raise OwnedByTUI, sid if owner.tui?
|
|
281
281
|
next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
|
|
282
282
|
|
|
283
283
|
raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
|
|
@@ -374,12 +374,12 @@ module Samagotchi
|
|
|
374
374
|
def self.resume_session(session_id, state_dir: nil)
|
|
375
375
|
sd = state_dir || Session.default_state_dir
|
|
376
376
|
session = Session.load(session_id, state_dir: sd)
|
|
377
|
-
|
|
378
|
-
raise OwnedByTUI, session.id if owner && owner["kind"] == "tui"
|
|
379
|
-
return session if owner
|
|
377
|
+
return session if refuse_tui!(session.id, state_dir: sd)
|
|
380
378
|
|
|
381
379
|
# status is turn state, not liveness: a new worker runs no turn yet,
|
|
382
|
-
# and must not find the session stopped (it would exit)
|
|
380
|
+
# and must not find the session stopped (it would exit): neither the
|
|
381
|
+
# marker nor a status field from before it.
|
|
382
|
+
Session.clear_stopped(session.id, state_dir: sd)
|
|
383
383
|
session.status = Session::STATUS_IDLE
|
|
384
384
|
session.save(state_dir: sd)
|
|
385
385
|
spawn_worker_for_session(session, state_dir: sd)
|
|
@@ -404,13 +404,12 @@ module Samagotchi
|
|
|
404
404
|
# @raise [OwnedByTUI] when the interactive TUI owns the session
|
|
405
405
|
def self.stop_session(session_id, state_dir: nil, wait: nil)
|
|
406
406
|
sd = state_dir || Session.default_state_dir
|
|
407
|
-
owner =
|
|
408
|
-
raise OwnedByTUI, session_id if owner && owner["kind"] == "tui"
|
|
407
|
+
owner = refuse_tui!(session_id, state_dir: sd)
|
|
409
408
|
|
|
410
409
|
cancel_running_turn(session_id, state_dir: sd)
|
|
411
410
|
# Mark first: a worker that has not taken the lock yet sees it and exits.
|
|
412
411
|
Session.mark_stopped(session_id, state_dir: sd)
|
|
413
|
-
pid = owner
|
|
412
|
+
pid = owner&.pid.to_i
|
|
414
413
|
begin
|
|
415
414
|
Process.kill("TERM", pid) if pid && pid > 0
|
|
416
415
|
rescue Errno::ESRCH
|
|
@@ -545,9 +544,8 @@ module Samagotchi
|
|
|
545
544
|
dir = Session.session_dir(id, state_dir: sd)
|
|
546
545
|
raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
|
|
547
546
|
|
|
548
|
-
owner =
|
|
547
|
+
owner = refuse_tui!(id, state_dir: sd)
|
|
549
548
|
if owner
|
|
550
|
-
raise OwnedByTUI, id if owner["kind"] == "tui"
|
|
551
549
|
raise DeleteRefused.new(id, :worker_running) unless stop
|
|
552
550
|
raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
|
|
553
551
|
end
|
|
@@ -594,6 +592,7 @@ module Samagotchi
|
|
|
594
592
|
# @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
|
|
595
593
|
# @param poll_interval [Numeric, nil] seconds between the loop's fallback
|
|
596
594
|
# ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
|
|
595
|
+
# A stopped session's worker exits 0 and a crashed one 1, there.
|
|
597
596
|
# @return [Symbol] :idle_exit or :exit_requested
|
|
598
597
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
599
598
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
@@ -619,6 +618,10 @@ module Samagotchi
|
|
|
619
618
|
@owner_lock.release
|
|
620
619
|
end
|
|
621
620
|
Log.info(:worker, "stop", reason: result)
|
|
621
|
+
# A stopped session is left as it is: no resume for input that came
|
|
622
|
+
# in, no discard.
|
|
623
|
+
exit(0) if result == :stopped
|
|
624
|
+
exit(1) if result == :crashed
|
|
622
625
|
# Only after the release: a writer that still saw this worker as the
|
|
623
626
|
# owner may have queued input since the last check. Either it finds no
|
|
624
627
|
# owner after its write and wakes one, or this finds its input.
|
|
@@ -776,7 +779,7 @@ module Samagotchi
|
|
|
776
779
|
owner = delivery_owner(manager, session_id, state_dir)
|
|
777
780
|
# A TUI that took the session between the resume and the write never
|
|
778
781
|
# reads input files; a later worker would replay this one.
|
|
779
|
-
if owner&.
|
|
782
|
+
if owner&.tui?
|
|
780
783
|
FileUtils.rm_f(path) if path.is_a?(String)
|
|
781
784
|
raise OwnedByTUI, session_id
|
|
782
785
|
end
|
|
@@ -810,10 +813,21 @@ module Samagotchi
|
|
|
810
813
|
# The session's live owner: the OwnerLock holder. The pid file workers
|
|
811
814
|
# once wrote is never read: a stale one whose pid the OS reused would
|
|
812
815
|
# make a session look owned.
|
|
813
|
-
# @return [
|
|
816
|
+
# @return [OwnerLock::Owner, nil]
|
|
814
817
|
def self.session_owner(session_id, state_dir: nil)
|
|
815
818
|
OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
|
|
816
819
|
end
|
|
820
|
+
|
|
821
|
+
# The session's owner (#session_owner), unless it is the interactive
|
|
822
|
+
# TUI, which nothing else may resume, stop or delete.
|
|
823
|
+
# @return [OwnerLock::Owner, nil]
|
|
824
|
+
# @raise [OwnedByTUI]
|
|
825
|
+
def self.refuse_tui!(session_id, state_dir: nil)
|
|
826
|
+
owner = session_owner(session_id, state_dir: state_dir)
|
|
827
|
+
raise OwnedByTUI, session_id if owner&.tui?
|
|
828
|
+
|
|
829
|
+
owner
|
|
830
|
+
end
|
|
817
831
|
end
|
|
818
832
|
end
|
|
819
833
|
|